From 5b9d0260bba4b641fb8ab3e3a5449c85a2d559f5 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 23 Aug 2026 21:46:05 +0200 Subject: [PATCH 01/89] =?UTF-8?q?docs=20#429:=20root=20cause=20=E2=80=94?= =?UTF-8?q?=2030-77MB/frame=20LINQ=20allocation=20in=20streamed-mesh=20com?= =?UTF-8?q?pletion=20on=20the=20render=20thread;=20fix=20plan=20+=20accept?= =?UTF-8?q?ance=20protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local commit for the implementation handoff; push withheld per owner direction until the fix session. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index fd4b9ed5..375816d1 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -148,6 +148,35 @@ of running per arm (`artifacts/owner-gate/player-present-429-packON.csv` frames spend their extra time (the pack's CPU stage profiler names stages) and where the player position is sampled relative to it. +**ROOT CAUSE FOUND 2026-08-23 (frame-history run, 33,350 frames with stage +attribution — `ACDREAM_FRAME_PROF=1` + `ACDREAM_FRAME_HISTORY`):** of 708 +stall frames (>12 ms), 701 allocate 30-77 MB IN THAT SINGLE FRAME (normal +frames: ~22 KB, p99 94 KB). The time and the allocation sit on the render +path outside the tracked stages. The bursts arrive in ~8-frame clusters +during movement — the streaming MESH COMPLETION path: each frame completes +up to `MaxCompletionsPerFrame` (quality High = 4) newly streamed meshes on +the render thread, and each completion in +`ObjectMeshManager.UploadGfxObjMeshData` (~line 2043) runs LINQ chains — +`TextureBatches.Values.SelectMany(...).Select(b => b.Indices.ToArray()) +.ToArray()`, plus the retained pick-support copies +`CPUPositions = Vertices.Select(v => v.Position).ToArray()` and +`CPUIndices = ...SelectMany(...).SelectMany(...).ToArray()` — megabytes of +enumerator/intermediate-List garbage per mesh, tens of MB per frame. +Gen0 runs 70-145 collections per 5 s during movement (vs ~1/6 s idle). + +**Fix shape (a bounded slice, not a quickie — this is the production mesh +pipeline):** +1. De-LINQ the conversion: direct pre-sized loops for the index batches and + the CPU pick copies (sizes are known up front from the batch counts). +2. Consider byte-budgeted completions (4 huge EnvCell meshes is not the + same frame cost as 4 fence posts) and/or moving the CPU-side conversion + onto the existing mesh-preparation scheduler thread so the render thread + only adopts finished arrays. +3. Allocation-gate test in the I1 style: a completion of a representative + mesh set must allocate near its retained-copy size, not multiples of it. +The pack-ON player-jump phase question remains as the second defect but +becomes mostly moot once the stalls themselves shrink. + **Next probes (in order):** 1. `ACDREAM_DUMP_MOTION=1` + a temporary inbound-position log for the LOCAL guid: does ACE send position sets for the local player every From 0330fcd0d18c5c272ffbdb49af2560c3947e9121 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 09:14:00 +0200 Subject: [PATCH 02/89] perf(render) #429: allocation-exact streamed-mesh completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UploadGfxObjMeshData built every completed mesh's index data three-plus times over in LINQ transients (per-batch Indices.ToArray copies plus an unsized SelectMany growth) on the render thread, up to the per-frame upload budget. The conversion now fills one exact-size retained CPUIndices array (the same one the B.4b pick path keeps) and hands the shared arena (offset, count) segments of it; CPUPositions fills by a direct pre-sized loop; the Sum/Any/FirstOrDefault transients are gone. GlobalMeshBuffer.UploadMesh takes the segment form — the staged bytes per batch are unchanged. Gate: a warmed completion must allocate near its retained-copy size (MeshPipelineDeviceSeamTests). Co-Authored-By: Claude Fable 5 --- .../Rendering/Wb/GlobalMeshBuffer.cs | 42 +++++--- .../Rendering/Wb/ObjectMeshManager.cs | 101 +++++++++++++++--- .../Wb/MeshPipelineDeviceSeamTests.cs | 74 ++++++++++++- 3 files changed, 183 insertions(+), 34 deletions(-) diff --git a/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs index 9211cb68..34811e7e 100644 --- a/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs +++ b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs @@ -286,9 +286,15 @@ public sealed class GlobalMeshBuffer : IDisposable } } + /// + /// #429: index batches arrive as (offset, count) segments of one shared + /// index array — the caller's retained CPU pick copy — instead of one + /// managed array per batch. The bytes staged per batch are unchanged. + /// internal GlobalMeshAllocation UploadMesh( VertexPositionNormalTexture[] vertices, - IReadOnlyList indexBatches) + ushort[] indices, + ReadOnlySpan<(int Offset, int Count)> indexBatches) { ObjectDisposedException.ThrowIf(_disposed, this); _retirementLedger.RetryPendingPublications(); @@ -296,16 +302,21 @@ public sealed class GlobalMeshBuffer : IDisposable if (_migration is not null) throw new InvalidOperationException("A mesh upload cannot mutate the arena while a backing-buffer migration is in progress."); ArgumentNullException.ThrowIfNull(vertices); - ArgumentNullException.ThrowIfNull(indexBatches); + ArgumentNullException.ThrowIfNull(indices); if (vertices.Length == 0) throw new ArgumentException("A global mesh allocation requires vertices.", nameof(vertices)); int totalIndices = 0; - for (int i = 0; i < indexBatches.Count; i++) + for (int i = 0; i < indexBatches.Length; i++) { - ushort[] batch = indexBatches[i] - ?? throw new ArgumentException("Index batches cannot contain null.", nameof(indexBatches)); - totalIndices = checked(totalIndices + batch.Length); + (int offset, int count) = indexBatches[i]; + if (offset < 0 || count <= 0 || (long)offset + count > indices.Length) + { + throw new ArgumentException( + $"Index batch {i} ({offset}, {count}) is outside the shared index array ({indices.Length}).", + nameof(indexBatches)); + } + totalIndices = checked(totalIndices + count); } if (totalIndices == 0) throw new ArgumentException("A global mesh allocation requires indices.", nameof(indexBatches)); @@ -322,7 +333,7 @@ public sealed class GlobalMeshBuffer : IDisposable throw; } - var firstIndices = new int[indexBatches.Count]; + var firstIndices = new int[indexBatches.Length]; try { // IGpuBuffer.Upload stages through a neutral binding point of the @@ -337,18 +348,15 @@ public sealed class GlobalMeshBuffer : IDisposable IGpuBuffer indexStore = RequireStore(_indexBuffer); int indexOffset = indexRange.Offset; - for (int i = 0; i < indexBatches.Count; i++) + for (int i = 0; i < indexBatches.Length; i++) { - ushort[] batch = indexBatches[i]; + (int offset, int count) = indexBatches[i]; firstIndices[i] = indexOffset; - if (batch.Length > 0) - { - long indexOffsetBytes = checked((long)indexOffset * sizeof(ushort)); - indexStore.Upload( - indexOffsetBytes, - MemoryMarshal.AsBytes(new ReadOnlySpan(batch))); - indexOffset = checked(indexOffset + batch.Length); - } + long indexOffsetBytes = checked((long)indexOffset * sizeof(ushort)); + indexStore.Upload( + indexOffsetBytes, + MemoryMarshal.AsBytes(new ReadOnlySpan(indices, offset, count))); + indexOffset = checked(indexOffset + count); } } catch diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index 25a6bc42..f8030062 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -2045,13 +2045,47 @@ namespace AcDream.App.Rendering.Wb { if (meshData.Vertices.Length == 0) return null; - var modernIndexBatches = meshData.TextureBatches.Values - .SelectMany(batches => batches) - .Where(batch => batch.Indices.Count != 0) - .Select(batch => batch.Indices.ToArray()) - .ToArray(); + // #429: allocation-exact conversion. The retained pick copies + // (CPUPositions/CPUIndices) are the only geometry-proportional + // allocations this method makes; the per-batch segments handed to + // the shared arena are (offset, count) views into the same + // retained CPUIndices array. The former LINQ chain materialized + // every batch twice (a per-batch ToArray plus an unsized + // SelectMany growth) — megabytes of transient garbage per mesh on + // the render thread. + int totalIndexCount = 0; + int nonEmptyBatchCount = 0; + foreach (List formatBatches in meshData.TextureBatches.Values) + { + foreach (TextureBatchData formatBatch in formatBatches) + { + if (formatBatch.Indices.Count == 0) continue; + totalIndexCount = checked(totalIndexCount + formatBatch.Indices.Count); + nonEmptyBatchCount++; + } + } + + var cpuIndices = new ushort[totalIndexCount]; + var indexSegments = new (int Offset, int Count)[nonEmptyBatchCount]; + { + int fillOffset = 0; + int segmentIndex = 0; + foreach (List formatBatches in meshData.TextureBatches.Values) + { + foreach (TextureBatchData formatBatch in formatBatches) + { + int count = formatBatch.Indices.Count; + if (count == 0) continue; + CollectionsMarshal.AsSpan(formatBatch.Indices) + .CopyTo(cpuIndices.AsSpan(fillOffset, count)); + indexSegments[segmentIndex++] = (fillOffset, count); + fillOffset = checked(fillOffset + count); + } + } + } + GlobalMeshAllocation? globalAllocation = null; - var renderBatches = new List(); + var renderBatches = new List(nonEmptyBatchCount); var acquiredTextures = new List<(TextureAtlasManager Atlas, TextureKey Key)>(); try @@ -2068,8 +2102,13 @@ namespace AcDream.App.Rendering.Wb // or vertex/index buffer to build here — Vulkan bakes vertex input // into the pipeline, and the shared arena's stores are bound once // per pass (see WbDrawDispatcher.Rhi.cs's BindPipelineWithMesh). - if (GlobalBuffer is not null && modernIndexBatches.Length != 0) - globalAllocation = GlobalBuffer.UploadMesh(meshData.Vertices, modernIndexBatches); + if (GlobalBuffer is not null && nonEmptyBatchCount != 0) + { + globalAllocation = GlobalBuffer.UploadMesh( + meshData.Vertices, + cpuIndices, + indexSegments); + } foreach (var (format, batches) in meshData.TextureBatches) { @@ -2093,8 +2132,25 @@ namespace AcDream.App.Rendering.Wb // family. Choosing an earlier reclaimed slot first // duplicates a layer already resident in a later array // every time portal churn revisits that texture. - atlasManager = atlasList.FirstOrDefault(a => a.HasTexture(batch.Key)) - ?? atlasList.FirstOrDefault(a => a.AvailableSlots > 0); + for (int i = 0; i < atlasList.Count; i++) + { + if (atlasList[i].HasTexture(batch.Key)) + { + atlasManager = atlasList[i]; + break; + } + } + if (atlasManager is null) + { + for (int i = 0; i < atlasList.Count; i++) + { + if (atlasList[i].AvailableSlots > 0) + { + atlasManager = atlasList[i]; + break; + } + } + } if (atlasManager == null) { atlasManager = new TextureAtlasManager( @@ -2180,21 +2236,34 @@ namespace AcDream.App.Rendering.Wb } } + // Every renderBatches entry is one non-empty texture batch, so + // their index counts sum to exactly totalIndexCount. long geometryBytes = checked( (long)meshData.Vertices.Length * VertexPositionNormalTexture.Size - + renderBatches.Sum(b => (long)b.IndexCount * sizeof(ushort))); + + (long)totalIndexCount * sizeof(ushort)); + bool hasCutoutSubset = false; + for (int i = 0; i < renderBatches.Count; i++) + { + if (renderBatches[i].Translucency + == AcDream.Core.Meshing.TranslucencyKind.ClipMap) + { + hasCutoutSubset = true; + break; + } + } + var cpuPositions = new Vector3[meshData.Vertices.Length]; + for (int i = 0; i < cpuPositions.Length; i++) + cpuPositions[i] = meshData.Vertices[i].Position; var renderData = new ObjectRenderData { VertexCount = meshData.Vertices.Length, Batches = renderBatches, - HasCutoutSubset = renderBatches.Any( - static batch => batch.Translucency - == AcDream.Core.Meshing.TranslucencyKind.ClipMap), + HasCutoutSubset = hasCutoutSubset, GlobalAllocation = globalAllocation, ParticleEmitters = meshData.ParticleEmitters, DIDDegrade = meshData.DIDDegrade, - CPUPositions = meshData.Vertices.Select(v => v.Position).ToArray(), - CPUIndices = meshData.TextureBatches.Values.SelectMany(l => l).SelectMany(b => b.Indices).ToArray(), + CPUPositions = cpuPositions, + CPUIndices = cpuIndices, CPUEdgeLines = meshData.EdgeLines, MemorySize = geometryBytes, NonArenaGpuBytes = CalculateNonArenaGeometryBytes( diff --git a/tests/AcDream.App.Tests/Rendering/Wb/MeshPipelineDeviceSeamTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/MeshPipelineDeviceSeamTests.cs index ff9f00a6..48cdb2c5 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/MeshPipelineDeviceSeamTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/MeshPipelineDeviceSeamTests.cs @@ -207,7 +207,10 @@ public sealed class MeshPipelineDeviceSeamTests vertices[2].Position = new System.Numerics.Vector3(7f, 8f, 9f); ushort[] indices = [0, 1, 2]; - GlobalMeshAllocation allocation = arena.UploadMesh(vertices, [indices]); + GlobalMeshAllocation allocation = arena.UploadMesh( + vertices, + indices, + [(0, indices.Length)]); Assert.Equal(3, allocation.Vertices.Length); Assert.Equal(3, allocation.Indices.Length); @@ -229,6 +232,75 @@ public sealed class MeshPipelineDeviceSeamTests System.Runtime.InteropServices.MemoryMarshal.Cast(indexBytes).ToArray()); } + /// + /// #429 allocation gate (I1 style). Completing a prepared mesh on the + /// render thread must allocate near its retained pick-copy size + /// (CPUPositions + CPUIndices), not multiples of it. The regression this + /// pins: the upload conversion ran LINQ chains — a per-batch + /// Indices.ToArray() plus an unsized SelectMany().ToArray() + /// — that materialized every index three-plus times in transient garbage + /// per completed mesh, on the render thread, up to the per-frame upload + /// budget. + /// + [Fact] + public void AWarmedMeshCompletionAllocatesNearItsRetainedCopySize() + { + using var device = new RecordingGpuDevice(); + using ObjectMeshManager manager = Build(device, modernPath: true); + + // Warm: an identically shaped mesh grows the arena, the atlas family, + // and every pool the completion path touches. + Assert.NotNull(manager.UploadMeshData( + CreateLargeMeshData(0x0100AA01u, surfaceSeed: 0x08000000u))); + + ObjectMeshData meshData = + CreateLargeMeshData(0x0100AA02u, surfaceSeed: 0x08001000u); + long before = GC.GetAllocatedBytesForCurrentThread(); + ObjectRenderData? uploaded = manager.UploadMeshData(meshData); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.NotNull(uploaded); + long retained = + (long)uploaded!.CPUIndices.Length * sizeof(ushort) + + (long)uploaded.CPUPositions.Length * 3 * sizeof(float); + // Sanity: the fixture is actually index-heavy enough to discriminate. + Assert.True(retained >= 480_000, $"fixture retained only {retained} bytes"); + // The LINQ regression allocates over 3x the index bytes and fails + // this bound by more than a megabyte. + long bound = retained + retained / 2 + 128 * 1024; + Assert.True( + allocated < bound, + $"A warmed mesh completion allocated {allocated} bytes " + + $"(retained copies {retained}, bound {bound})."); + } + + private static ObjectMeshData CreateLargeMeshData(ulong id, uint surfaceSeed) + { + const int vertexCount = 1024; + const int batchCount = 4; + const int indicesPerBatch = 60_000; + var data = new ObjectMeshData + { + ObjectId = id, + Vertices = new VertexPositionNormalTexture[vertexCount], + }; + var batches = new System.Collections.Generic.List(batchCount); + for (int b = 0; b < batchCount; b++) + { + var indices = new System.Collections.Generic.List(indicesPerBatch); + for (int i = 0; i < indicesPerBatch; i++) + indices.Add((ushort)((i + b) % vertexCount)); + batches.Add(new TextureBatchData + { + Key = new TextureKey { SurfaceId = surfaceSeed + (uint)b }, + TextureData = new byte[8 * 8 * 4], + Indices = indices, + }); + } + data.TextureBatches[(8, 8, TextureFormat.RGBA8)] = batches; + return data; + } + /// /// The production Vulkan implementation of the seam, checked against the /// same surface. Its two capability flags answer true because what they From 4873c10673a7d630b4c36cd3e3a61ad80692aa59 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 09:16:41 +0200 Subject: [PATCH 03/89] fix(runtime/camera) #429: presented player and chase camera share the object clock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of the felt run-hitch (the visible one-frame player lurch): - The presentation lerp normalized the pending object-clock time by the fixed 30 Hz MinQuantum, but retail's object clock simulates VARIABLE-length quanta (CPhysicsObj::update_object 0x00515D10: capped at MaxQuantum, everything above MinQuantum runs as ONE step). After a long frame the view froze for the quantum and then fast-replayed it. ComputeRenderPosition now spans the ACTUAL last quantum (_lastQuantumSeconds), and PresentedDeltaSeconds accounts continuous presented time across quantum boundaries. - The chase camera damped toward the presented player using wall dt while the player presents on the object clock, so a long frame stepped the camera far past the under-advanced player — measured up to ~1 m of camera/player decoherence in a single frame. Retail ties camera update to the physics-update callback (SmartBox::PlayerPhysicsUpdatedCallback 0x00452d60), i.e. the same clock as the body; both chase cameras now integrate PresentedDeltaSeconds. Manual zoom/pitch adjustment stays on wall dt (a user-input rate, not target chasing). Owner gate: camera-vs-player boom-length change fell from ~1 m spikes to 0.2-1.2 cm median on long frames; teleports settle clean. Two Runtime tests updated to pin the continuous-rate contract. The temporary PlayerPresentationProbe apparatus that measured this is retired with the fix. Co-Authored-By: Claude Fable 5 --- .../Rendering/CameraFrameController.cs | 16 +++- .../Rendering/PlayerPresentationProbe.cs | 75 ------------------- .../Rendering/WorldRenderFrameBuilder.cs | 8 -- .../Gameplay/PlayerMovementController.cs | 72 +++++++++++++++++- .../Gameplay/PlayerMovementControllerTests.cs | 39 ++++++---- 5 files changed, 110 insertions(+), 100 deletions(-) delete mode 100644 src/AcDream.App/Rendering/PlayerPresentationProbe.cs diff --git a/src/AcDream.App/Rendering/CameraFrameController.cs b/src/AcDream.App/Rendering/CameraFrameController.cs index ff56239d..949942d7 100644 --- a/src/AcDream.App/Rendering/CameraFrameController.cs +++ b/src/AcDream.App/Rendering/CameraFrameController.cs @@ -94,11 +94,23 @@ internal sealed class CameraFrameController : ICameraFramePhase _spatialReconciler.Reconcile(); MovementResult result = playerFrame.Movement; + // #429 defect 2: the chase camera smooths toward the PRESENTED player + // position, which lives on the retail 30 Hz object clock (see + // PlayerMovementController.PresentedDeltaSeconds). Integrating the + // damping with wall dt made the camera step full wall time on long + // frames while the presented player under-advanced against the + // quantum — measured ~1 m of camera/player decoherence in one frame, + // the felt run-hitch. Retail ties the camera to the physics-update + // callback (SmartBox::PlayerPhysicsUpdatedCallback 0x00452d60), i.e. + // the same clock as the body; the presented delta restores that. + // Manual zoom/pitch adjustment above stays on wall dt — it is a + // user-input rate, not target chasing. + float cameraDt = controller.PresentedDeltaSeconds; legacy.Update( result.RenderPosition, controller.Yaw, isOnGround: result.IsOnGround, - dt: timing.SimulationDeltaSecondsSingle); + dt: cameraDt); retail?.Update( result.RenderPosition, @@ -106,7 +118,7 @@ internal sealed class CameraFrameController : ICameraFramePhase playerVelocity: controller.BodyVelocity, isOnGround: result.IsOnGround, contactPlaneNormal: controller.ContactPlane.Normal, - dt: timing.SimulationDeltaSecondsSingle, + dt: cameraDt, cellId: controller.CellId, selfEntityId: controller.LocalEntityId, trackedTargetPoint: _combatTarget.GetTrackedTargetPoint()); diff --git a/src/AcDream.App/Rendering/PlayerPresentationProbe.cs b/src/AcDream.App/Rendering/PlayerPresentationProbe.cs deleted file mode 100644 index d3a471b0..00000000 --- a/src/AcDream.App/Rendering/PlayerPresentationProbe.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Diagnostics; -using System.Globalization; -using System.Numerics; - -namespace AcDream.App.Rendering; - -/// -/// TEMPORARY #429 apparatus. One CSV row per rendered frame: -/// seconds,camX,camY,camZ,playerX,playerY,playerZ, where seconds is a -/// Stopwatch-derived monotonic time and player is the frame's -/// PlayerViewPosition — the presented local-player position driving -/// lighting/visibility this frame. Off unless -/// ACDREAM_PROBE_PLAYER_PRESENT=<path> is set (one null check per -/// frame). Analysis: a hitch frame shows the player's per-frame delta -/// collapsing to ~0 or doubling; whether the SAME frame's dt is smooth -/// separates a presentation-phase bug (pack frame graph sampling a stale -/// snapshot) from genuine frame-pacing spikes. Strip with the #429 fix. -/// -internal sealed class PlayerPresentationProbe : IDisposable -{ - private readonly StreamWriter _writer; - private readonly long _startTimestamp = Stopwatch.GetTimestamp(); - private int _linesSinceFlush; - - private PlayerPresentationProbe(StreamWriter writer) - { - _writer = writer; - _writer.WriteLine("seconds,camX,camY,camZ,playerX,playerY,playerZ"); - } - - internal static PlayerPresentationProbe? CreateFromEnvironment() - { - string? path = Environment.GetEnvironmentVariable("ACDREAM_PROBE_PLAYER_PRESENT"); - if (string.IsNullOrWhiteSpace(path)) - return null; - - try - { - return new PlayerPresentationProbe(new StreamWriter(path, append: false)); - } - catch (Exception ex) - { - Console.Error.WriteLine( - $"[player-present] probe file '{path}' could not be opened: {ex.Message}"); - return null; - } - } - - internal void Observe(in Vector3 cameraPosition, in Vector3 playerViewPosition) - { - double seconds = (Stopwatch.GetTimestamp() - _startTimestamp) - / (double)Stopwatch.Frequency; - _writer.WriteLine(string.Create( - CultureInfo.InvariantCulture, - $"{seconds:F6},{cameraPosition.X:F4},{cameraPosition.Y:F4},{cameraPosition.Z:F4},{playerViewPosition.X:F4},{playerViewPosition.Y:F4},{playerViewPosition.Z:F4}")); - if (++_linesSinceFlush >= 240) - { - _linesSinceFlush = 0; - _writer.Flush(); - } - } - - public void Dispose() - { - try - { - _writer.Flush(); - _writer.Dispose(); - } - catch - { - // A probe must never turn teardown fallible. - } - } -} diff --git a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs index 86be4d0a..7fdc66ae 100644 --- a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs +++ b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs @@ -450,10 +450,6 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation private readonly HashSet _visibleCells = []; private bool _visibleCellsValid; - /// TEMPORARY #429 — see . - private readonly PlayerPresentationProbe? _playerPresentProbe = - PlayerPresentationProbe.CreateFromEnvironment(); - public RuntimeWorldFrameEnvironmentPreparation( RuntimeOptions options, WorldTimeService worldTime, @@ -489,10 +485,6 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation activeDayGroup, camera.Position); - // TEMPORARY #429 apparatus — one CSV row per frame; off unless - // ACDREAM_PROBE_PLAYER_PRESENT names a file. Strip with the fix. - _playerPresentProbe?.Observe(camera.Position, roots.PlayerViewPosition); - UpdateSunFromSky(foundation.Sky, roots.PlayerInsideCell); _lighting.UpdateViewerLight(roots.PlayerViewPosition); _lighting.Tick(camera.Position); diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index 3714e39f..66ed3c81 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -344,6 +344,60 @@ public sealed class PlayerMovementController /// internal bool AdvancedObjectQuantumLastTick { get; private set; } + /// + /// #429 defect 2: how far the PRESENTED position's own clock advanced in + /// the last tick, in seconds. The presented position + /// () lives on the retail 30 Hz object clock — + /// a lerp between the last two simulated quanta whose alpha clamps at 1 — + /// so near-quantum-length host frames alias against the 33.3 ms quantum + /// and the presented position under-advances relative to wall time. Any + /// consumer that smooths toward the presented position (the chase camera) + /// must integrate THIS delta, not the host frame's wall dt, or the two + /// visibly decohere on long frames (measured at ~1 m in one frame, the + /// felt run-hitch). Retail ties its camera to the physics-update callback + /// (SmartBox::PlayerPhysicsUpdatedCallback 0x00452d60) — the same + /// clock — which is the behavior this delta restores. + /// + public float PresentedDeltaSeconds { get; private set; } + + /// + /// Seconds spanned by the prev→curr snapshot pair the presentation lerp + /// interpolates across — the length of the LAST simulated quantum, which + /// the retail clock makes variable (MinQuantum..MaxQuantum). Maintained by + /// , the single per-tick chokepoint. + /// + private float _lastQuantumSeconds = PhysicsBody.MinQuantum; + + private float ComputePresentedDelta( + float wallDt, + double pendingBeforeSeconds, + in RetailObjectQuantumBatch batch) + { + if (batch.Discarded) + { + // The lerp base was reset to the current body position — the + // presented position snapped. Hand the camera the wall step so it + // snaps along rather than freezing mid-teleport. + _lastQuantumSeconds = PhysicsBody.MinQuantum; + return wallDt; + } + // Presented time = t(curr) − lastInterval + min(pending, lastInterval): + // continuous across quantum fires, long-run rate 1 with bounded jitter. + // The per-tick advance is the simulated seconds plus the phase change. + float previousInterval = _lastQuantumSeconds; + float simulated = 0f; + if (batch.Count > 0) + { + simulated = batch.FullSteps * PhysicsBody.MaxQuantum + batch.Remainder; + _lastQuantumSeconds = batch.GetQuantum(batch.Count - 1); + } + float interval = _lastQuantumSeconds; + float before = Math.Min((float)pendingBeforeSeconds, previousInterval); + float after = Math.Min((float)_objectClock.PendingSeconds, interval); + float delta = simulated + (after - interval) - (before - previousInterval); + return Math.Max(delta, 0f); + } + /// /// Returns retail's canonical outbound Position: the physics body's /// carried cell id plus its landblock-local frame origin. Retail @@ -1043,6 +1097,7 @@ public sealed class PlayerMovementController { EnsurePublishedForRuntimeOperation(); AdvancedObjectQuantumLastTick = false; + PresentedDeltaSeconds = 0f; if (float.IsFinite(elapsedSeconds) && elapsedSeconds > 0f) _simTimeSeconds += elapsedSeconds; _objectClock.Deactivate(); @@ -2188,8 +2243,16 @@ public sealed class PlayerMovementController private Vector3 ComputeRenderPosition() { + // #429 defect 2 (residual): the prev→curr snapshot pair spans the LAST + // SIMULATED QUANTUM, whose length is variable — the retail clock + // simulates everything above MinQuantum in one step, so a long host + // frame produces a 34-100 ms quantum. Normalizing the interpolation by + // the fixed MinQuantum made the presented position freeze the frame a + // long quantum fired (alpha reset to 0 across a bigger gap) and then + // replay at gap/MinQuantum speed — the residual whole-view hiccup + // after the camera-clock fix. Normalize by the actual interval. float alpha = Math.Clamp( - (float)(_objectClock.PendingSeconds / PhysicsBody.MinQuantum), + (float)(_objectClock.PendingSeconds / _lastQuantumSeconds), 0f, 1f); return Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha); @@ -2206,6 +2269,7 @@ public sealed class PlayerMovementController { EnsurePublishedForRuntimeOperation(); AdvancedObjectQuantumLastTick = false; + PresentedDeltaSeconds = 0f; if (!float.IsFinite(dt) || dt <= 0f) { return CapturePresentationResult() with @@ -2216,12 +2280,14 @@ public sealed class PlayerMovementController } _simTimeSeconds += dt; + double pendingBeforeSeconds = _objectClock.PendingSeconds; bool reactivated = _objectClock.Activate(); _body.TransientState |= TransientStateFlags.Active; RetailObjectQuantumBatch batch = reactivated ? default : _objectClock.Advance(dt); AdvancedObjectQuantumLastTick = batch.Count > 0; + PresentedDeltaSeconds = ComputePresentedDelta(dt, pendingBeforeSeconds, in batch); if (batch.Discarded) { _prevPhysicsPos = _body.Position; @@ -2326,6 +2392,7 @@ public sealed class PlayerMovementController { EnsurePublishedForRuntimeOperation(); AdvancedObjectQuantumLastTick = false; + PresentedDeltaSeconds = 0f; // Reject a malformed host-frame duration at the controller boundary. // The retail object clock cannot sanitize state that input/jump/yaw // code already mutated; in particular Infinity would never converge @@ -2641,12 +2708,15 @@ public sealed class PlayerMovementController // stale gaps above HugeQuantum. Every admitted quantum executes the // whole object update below; animation never runs on a render-only // fragment. + double pendingBeforeSeconds = _objectClock.PendingSeconds; bool reactivated = _objectClock.Activate(); _body.TransientState |= TransientStateFlags.Active; RetailObjectQuantumBatch quantumBatch = reactivated ? default : _objectClock.Advance(dt); AdvancedObjectQuantumLastTick = quantumBatch.Count > 0; + PresentedDeltaSeconds = + ComputePresentedDelta(dt, pendingBeforeSeconds, in quantumBatch); bool justLanded = false; if (quantumBatch.Discarded) { diff --git a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs index 3ddb04a1..1f794ed1 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs @@ -546,8 +546,14 @@ public class PlayerMovementControllerTests Assert.True(halfFrame.RenderPosition.X < firstTick.Position.X, $"Render X={halfFrame.RenderPosition.X} should stay between {start.X} and {firstTick.Position.X}"); - float expectedMidpoint = start.X + ((firstTick.Position.X - start.X) * 0.5f); - Assert.Equal(expectedMidpoint, halfFrame.RenderPosition.X, precision: 3); + // #429 defect 2 (residual): the interpolation normalizes by the LAST + // SIMULATED QUANTUM's length — here the ObjectTick-long remainder + // quantum, not the fixed MinQuantum — so the presented position + // advances at a continuous rate across variable-length quanta instead + // of freezing then over-speeding after a long host frame. + float alpha = (PhysicsBody.MinQuantum * 0.5f) / ObjectTick; + float expected = start.X + ((firstTick.Position.X - start.X) * alpha); + Assert.Equal(expected, halfFrame.RenderPosition.X, precision: 3); } [Fact] @@ -653,27 +659,32 @@ public class PlayerMovementControllerTests } [Fact] - public void Update_LeftoverAboveMinQuantum_ClampsRenderAlphaToCurrentPhysicsPosition() + public void Update_LeftoverAboveMinQuantum_InterpolatesAcrossTheActualQuantumInterval() { var engine = MakeFlatEngine(); var controller = new PlayerMovementController(engine); - controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f)); + var start = new Vector3(96f, 96f, 50f); + controller.SeedPlacementForTest(start, 0x0001, start); controller.Yaw = 0f; var result = controller.Update( PhysicsBody.MaxQuantum + PhysicsBody.MinQuantum, new MovementInput(Forward: true)); - // Tolerance, not decimal `precision:` — the AP-7 friction port (P2) - // shifts the velocity-fallback trajectory by micrometers, and - // Math.Round-based precision comparison fails when two essentially - // equal values straddle a 5e-5 rounding boundary (observed: X - // 96.3427505 vs 96.3427429 — a 7.6 µm gap rounding to 96.3428 vs - // 96.3427). The clamp contract is "render == physics for - // presentation"; 1 mm is far below visibility and boundary-immune. - Assert.Equal(result.Position.X, result.RenderPosition.X, tolerance: 1e-3f); - Assert.Equal(result.Position.Y, result.RenderPosition.Y, tolerance: 1e-3f); - Assert.Equal(result.Position.Z, result.RenderPosition.Z, tolerance: 1e-3f); + // #429 defect 2 (residual): one MaxQuantum step simulates and + // MinQuantum is retained as pending, so the prev→curr interpolation + // pair spans a MaxQuantum-long interval and the presented position + // sits MinQuantum INTO it — lerp(start, Position, Min/Max) — rather + // than clamping onto the authoritative body. The former clamp + // contract ("render == physics when leftover >= MinQuantum") + // presented a forward rate spike after every long host frame; the + // continuous-rate contract is what keeps the presentation and the + // chase camera (which integrates PresentedDeltaSeconds) coherent. + float alpha = PhysicsBody.MinQuantum / PhysicsBody.MaxQuantum; + Vector3 expected = Vector3.Lerp(start, result.Position, alpha); + Assert.Equal(expected.X, result.RenderPosition.X, tolerance: 1e-3f); + Assert.Equal(expected.Y, result.RenderPosition.Y, tolerance: 1e-3f); + Assert.Equal(expected.Z, result.RenderPosition.Z, tolerance: 1e-3f); } [Fact] From ad69558908c86a112aa96d96724b15de6662eb04 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 09:16:58 +0200 Subject: [PATCH 04/89] fix #429: allocation-free shadow topology rebuild + churn-frame pipelining MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The directional-shadow topology rebuilt on every streaming-churn frame and was the measured body of the run-hitch stalls (701 of 708 baseline stalls alloc-correlated): - The draw sort comparer's enum-vs-enum CompareTo bound to Enum.CompareTo(object) and boxed BOTH operands on every comparison — a constant ~38.9 MB of garbage per topology rebuild (~4M boxes), handing the GC a forced gen0 collection mid-frame. The full ~100k-draw sort is replaced outright: draws hash-group by exact DrawKey in one O(n) pass over retained chained-index arrays, and only the few-thousand DISTINCT group keys sort (order-preserving packed material|cull|firstIndex|baseVertex + count|slot|layer|foliage keys, first-appearance tie-break) — bit-identical emission order to the old stable sort, near-zero allocation, and no per-draw comparisons at all. - The caster frame sorts 4-byte indices keyed on SortKey.Value instead of shuffling multi-hundred-byte records through a boxing comparer. - Owner-approved pipelining: on a frame whose shadow inputs just changed (the same frame already paying frame-view/landscape rebuilds), the caster-frame and prepared-draws topology rebuilds defer to the next quieter frame, capped at two consecutive deferrals — inside the GPU fence depth, so retained draws never reference a released arena range. First build, generation change, caster BuildSequence change, and journal overflow force the immediate path; deferred refreshes skip identity-mismatched journal rows. Owner-accepted in both presentation modes: stall frames 5.8/s -> ~0.45/s uncapped (0.49/s capped), median stall 20.3 -> 13.7 ms, >25 ms frames near zero, 275 fps uncapped baseline restored. Allocation gate: a warmed topology rebuild must allocate <2 KiB (DirectionalShadowPreparedDrawTests). docs/ISSUES.md carries the full evidence trail; the residual content-proportional rebuild milliseconds are filed as the incremental-topology successor, and the pre-existing town-view scaling latch is filed as #432. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 266 ++++++++++++++++++ .../Rendering/DirectionalSunShadowRenderer.cs | 11 +- .../Packs/AtmosphericPostProcessGraph.cs | 35 ++- .../Scene/DirectionalShadowCasterFrame.cs | 123 +++++++- .../Wb/WbDrawDispatcher.DirectionalShadows.cs | 203 +++++++++---- .../Wb/DirectionalShadowPreparedDrawTests.cs | 83 ++++++ 6 files changed, 652 insertions(+), 69 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 375816d1..97c45dbc 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -177,6 +177,272 @@ pipeline):** The pack-ON player-jump phase question remains as the second defect but becomes mostly moot once the stalls themselves shrink. +**SITE ATTRIBUTION CORRECTED + FIXED 2026-08-23 (implementation session).** +The frame-history correlation (701/708 stall frames at 30-77 MB) was right; +the SITE was wrong. The baseline CSV's own stage columns refute the +mesh-completion theory: in all 701 alloc-correlated stall frames +`upload_us` is ~3 µs — `WbMeshAdapter.Tick` (which contains the entire +`UploadGfxObjMeshData` completion drain AND the mip flush, inside the +tracked Upload stage) did essentially nothing in those frames. A temporary +per-phase allocation probe (`GC.GetAllocatedBytesForCurrentThread` marks +through the render frame, driven by an automated Holtburg running route) +attributed the allocation exactly: + +- **THE stall allocator: `DirectionalShadowPreparedDraws.Complete`'s + `Array.Sort` comparer** (`WbDrawDispatcher.DirectionalShadows.cs`). + `x.Material.CompareTo(y.Material)` / `x.CullMode.CompareTo(y.CullMode)` + bind to `Enum.CompareTo(object)`, which boxes BOTH operands on every + comparison — measured at a constant **38.88 MB per directional-shadow + topology rebuild** (~4M boxes across the N·log N sort of every prepared + caster draw in the resident window). The topology rebuilds whenever + `RenderDataAvailabilityVersion` moves — i.e. on every streaming-churn + frame while the player runs (the Atmospheric pack's shadow prepass is the + consumer, matching pack-ON visibility; sustained churn windows rebuilt + 260 consecutive frames ≈ 10 GB of garbage in seconds). Everything else + in the rebuild (caster copy, classification loop, grouping) measured + ~0.3 MB — the shadow stack's retained-scratch design was already sound; + two enum comparisons were the whole leak. +- Secondary (the original theory, real but ~7 of 708 baseline frames): + the `UploadGfxObjMeshData` LINQ conversion, 8-14 MB on completion + frames. +- Also observed while probing, pre-existing and bounded, NOT #429: + composite-texture warmup (`TickCompositeTextureCache`, 16/frame budget) + and PView reveal churn allocate ~4-30 MB on teleport/reveal frames. + +**Fix landed (pending owner gate):** +1. The sort comparer compares enums through their underlying integers — + allocation-free, identical ordering. Verified live: the 38.88 MB + rebuild signature is gone (big-alloc frames on the automated running + route: 124 → ~0 shadow-rebuild frames; only the pre-existing + composite/pview/reveal allocations remain). +2. The handoff's de-LINQ of `UploadGfxObjMeshData`: one exact-size + retained `CPUIndices` array now feeds both the pick copies and the + arena upload (`GlobalMeshBuffer.UploadMesh` takes (offset, count) + segments of it); `CPUPositions` fills by direct loop; the + `Sum`/`Any`/`FirstOrDefault` transients are gone. Behavior-preserving: + same bytes staged, same batch order, same retained content. +3. Two I1-style allocation gates: a warmed directional-shadow topology + rebuild must allocate < 2 KB + (`DirectionalShadowPreparedDrawTests.AWarmedTopologyRebuildAllocatesNearZero`), + and a warmed mesh completion must allocate near its retained-copy size + (`MeshPipelineDeviceSeamTests.AWarmedMeshCompletionAllocatesNearItsRetainedCopySize`). +4. The temporary `PlayerPresentationProbe` and the attribution probe are + stripped. + +Session note: one automated probe run (of seven) exited with +0xC0000374 (STATUS_HEAP_CORRUPTION) during graceful close AFTER the route +completed, on a diagnostic build; not reproduced since. Watch for it in +future gate runs. + +Remaining owed: the owner's two-sided acceptance (feel gate + the ~45 s +pack-ON measured run against `artifacts/owner-gate/frame-history-429.csv`). +The pack frame-graph ordering question (defect 2) stays deferred unless +residual hitches survive. + +**OWNER GATE ROUND 1 (2026-08-23, same evening): allocation half PASSED, +felt hitch PERSISTS — residual attributed and a second fix round landed.** +The owner's ~45 s pack-ON drive on the fixed build: alloc-correlated stalls +701 → 5, stall-frame allocation median 39.6 MB → 40 KB, GC pressure gone — +but the micro-freeze feel remained. A time-triggered probe round (print any +frame > 12 ms with per-phase attribution) on an owner-driven clean run +measured the residual exactly: **82 stalls in 47 s (1.75/s — the ORIGINAL +pre-investigation stall rate), median 19.1 ms, clusters every ~1.3-2 s**, +near-zero allocation. Composition per stall frame — a rebuild CASCADE all +triggered by one `RenderDataAvailabilityVersion` bump (streaming publish) +and all paid in the SAME frame: +- `pk:casters` 3.5-14 ms — `DirectionalShadowCasterFrame.Build` copies + + classifies every outdoor projection record; +- `wb:sd-topo` 4-28 ms — `DirectionalShadowPreparedDraws.Complete` + (sort + group), allocation-free after the boxing fix but still the CPU; +- `ws:pview` 4-12 ms — the world draw path's own version-keyed work, + elevated on the same frames. +The owner's "introduced with the night-sky change" hypothesis was tested +directly and REFUTED: the sky default-script segment (`b:skypes`) crossed +1 ms once (2.3 ms) in the whole run. Timing note: the VisualMaster +directional-shadow machinery landed immediately before the night-sky +session where the hitch was first noticed — the sky was the nearest +visible change, the shadow prepass the actual newcomer. + +**Second fix round (in tree, uncommitted): packed-key index sorts.** Both +hot sorts — `DirectionalShadowPreparedDraws.Complete`'s draw sort and +`DirectionalShadowCasterFrame.Build`'s caster sort — previously moved +multi-hundred-byte records through interface comparers. Both now sort +4-byte index arrays against one 64-bit key (draw sort: an order-preserving +packed prefix Material|CullMode|FirstIndex|BaseVertex with exact-comparer +tie-break; caster sort: the traversal `SortKey.Value` directly), then +permute once through retained scratch. Total order preserved everywhere +the arena can reach; 131 directional-shadow tests including both #429 +allocation gates pass. Owner re-drive pending at time of writing. + +**Measurement hygiene note:** an A/B (same launch recipe, same position, +with/without `ACDREAM_UI_PROBE_SCRIPT`, and with an idle one-command +script) proved the harness launch recipe and the script runner are BOTH +innocent of the #432 low-FPS mode — both idle arms run 4.5 ms/17 KB +frames. The mode requires the synthetic route's PATH (through the town +view); owner-driven runs avoid it naturally. + +**OVERNIGHT ROUNDS 2-4 (2026-08-23→24): rebuild cascade cheapened but the +felt hitch is defect 2, now MEASURED as camera/player decoherence.** +Owner drives 2-4 each reported the hitch "unchanged" while every attacked +piece shrank (caster copy+classify+sort ≈ 1.3-2 ms each; the draw sort +round-1 index sort actually REGRESSED — instanced duplicates share one +packed key, so the tie-break full-record comparer became the hot path, +6.5 → 14.6 ms avg, caught by the owner's drive-2 data — round-2 replaced +the 100k-draw sort entirely with O(n) hash-grouping over retained chained +arrays plus an O(g log g) sort of the ~few-thousand DISTINCT group keys; +same emitted product, deterministic). Post-everything, owner-terrain stalls +still ~2.1/s at 27-31 ms median: per-frame `pv:frameview` (scene +frame-view build) + `pv:landscape` + the residual topo loop dominate. +Micro-shaving converges too slowly to clear 12 ms — the FEEL lever is +defect 2. + +**Defect 2 objectively measured** (from the ORIGINAL owner captures +`player-present-429-packON/-packoff.csv`, camera-relative analysis): +pack ON, 50 of 85 long moving frames separate the presented player from +the camera by ~1 m in one frame (18x the normal 5.5 cm relative step); +pack OFF, 7 of 83 at half the size. The felt hitch IS this one-frame +camera/player decoherence — the player lurches on screen while the +camera-anchored world stays smooth. + +**MECHANISM LOCATED (2026-08-24 ~00:10, per-update +`ACDREAM_PROBE_CAMERA_TICK` capture, pack ON, running 16 m/s):** on +long (~27 ms) updates, HALF the samples advance the presented player only +~12-16 cm (a third of elapsed time) while the chase camera steps the full +~40 cm; the other half advance both coherently (~42-53 cm each). The two +run on DIFFERENT clocks: the presented player position is +`ComputeRenderPosition` = lerp(prevQuantum, currQuantum, +pending/MinQuantum) on the retail 30 Hz OBJECT CLOCK (alpha CLAMPS at 1 — +near-quantum-length updates alias against the 33.3 ms quantum and the +presented position under-advances or freezes), while the camera's damping +(`ComputeDampingAlpha(stiffness, dt)` in RetailChaseCamera/ChaseCamera) +integrates WALL-CLOCK dt — on a 27 ms update it closes ~half its +accumulated ~1 m chase lag regardless of the target having barely moved. +Camera and player cross → the lurch. High-FPS updates (pack OFF, ~4 ms) +glide through the quanta, which is why OFF feels smooth with the same +stall count — and why shrinking the stalls below ~quantum length would +also mask it, but the CLOCK MISMATCH is the root cause. + +**DEFECT-2 FIX IMPLEMENTED + MEASURED (2026-08-24 ~00:20, in tree, +uncommitted).** `PlayerMovementController.PresentedDeltaSeconds` now +reports how far the presented position's own clock advanced each tick +(quanta simulated x MinQuantum + clamped-pending delta; wall dt on a +Discarded/teleport batch so the camera snaps along), and +`CameraFrameController` integrates the chase-camera damping with THAT +delta instead of wall dt (manual zoom/pitch stays on wall dt — an input +rate, not target chasing). This RESTORES retail's semantics — the camera +updates on the physics clock via PlayerPhysicsUpdatedCallback +(0x00452d60) — so no divergence-register row: it retires an unregistered +wall-clock deviation. Verified on the automated route, pack ON: +- per long update (28-38 ms): cam/player step ratio 0.75-1.19 (was + 2.6-3.4x with ~27 cm crossing); |cam-player| med 2.2 cm, p90 7 cm, + max 16.7 cm (~12x tighter); +- per frame: typical camera-relative player step 5.5 cm → 0.3 cm; the + baseline's 50-of-85 ~1 m long-frame lurches → 3 frames above 25 cm + (max 31 cm) — better than the old pack-OFF arm the owner perceived as + smooth. +Runtime suite 1,818/0, hermetic App suite 6,082/0. + +**FEEL GATE ROUNDS 2-3 + THE FINAL TWO FIXES (2026-08-24 00:20-01:10).** +Round 2 ("still there") caught that the camera-clock fix alone leaves a +coherent whole-view freeze: with camera and player now in lockstep, the +remaining artifact was the presented position itself under-advancing. +Root cause: `ComputeRenderPosition` normalized its lerp by the FIXED +MinQuantum while the retail clock simulates VARIABLE-length quanta +(everything above MinQuantum in one step, split at MaxQuantum=0.2 s) — a +long host frame fired a >33 ms quantum, alpha reset across the bigger +gap, and presentation froze then replayed fast. Fixed by normalizing by +the actual last-quantum interval (`_lastQuantumSeconds`), with +`PresentedDeltaSeconds` accounting continuous presented time (the camera +consumes the same delta, so both stay coherent by construction). Two +Runtime tests that pinned the old fixed-quantum lerp were updated to the +continuous-rate contract (Update_SubQuantumFrame_..., +Update_LeftoverAboveMinQuantum_... — renamed +...InterpolatesAcrossTheActualQuantumInterval). + +Round 3 landed the owner-approved (A) **shadow rebuild pipelining**: on a +frame where the shadow inputs just changed (streaming churn — the same +frame already pays the frame-view/landscape rebuilds), the caster and +prepared-draws topology rebuilds defer to the next quieter frame, capped +at 2 consecutive deferrals (inside the GPU fence depth, so retained draws +can never reference a released-and-reused arena range). Deferral is +best-effort with hard safety rails: first build, generation change, +transform-journal overflow, and any caster rebuild force the full path +immediately; stale-topology refreshes skip identity-mismatched journal +rows instead of throwing. Implemented across +`DirectionalShadowCasterFrame.Build(allowTopologyRebuild)`, +`WbDrawDispatcher.PrepareDirectionalShadowDraws(allowTopologyRebuild)`, +and the policy in `AtmosphericPostProcessGraph.RenderDirectionalShadows`. + +**Measured outcome (owner feel gate 3, ~210 s drive incl. pack +switching): median stall 20.3 → 13.7 ms; automated route: med 13.9 ms +(was 27-31), max 33, frames >16 ms at 1.4/s. Owner verdict: "almost +gone."** Residual composition (deep marks): frame-view build ~4.7 ms + +early landscape slices ~3.9 ms per churn frame, plus the pipelined shadow +rebuild ~8 ms on its own frame — content-proportional work with no +pathological defect left; further reduction is the incremental-topology +campaign already described above. OWED: the owner's final morning +confirmation, then strip the probe families (RenderFrameAllocProbe + +marks incl. fv:/pl:, PlayerPresentationProbe, CameraTickProbe) and +commit on request. + +**Tree state at pause (uncommitted, on the worktree branch):** four landed +optimizations (enum-boxing comparer fix, upload de-LINQ + arena segment +API, caster-frame index sort, draw hash-grouping) + two allocation-gate +tests; TEMPORARY apparatus still wired: `RenderFrameAllocProbe` (env +`ACDREAM_PROBE_FRAME_ALLOC`, time-or-alloc triggered, 4/s print sampling) +with ~30 phase marks, `PlayerPresentationProbe` +(`ACDREAM_PROBE_PLAYER_PRESENT`), `CameraTickProbe` +(`ACDREAM_PROBE_CAMERA_TICK`). All env-gated, off by default; strip all +three families with the defect-2 fix. Hermetic App suite 6,082/0 (one +transient parallel-load flake observed once, known-flake class). + +--- + +## #432 — Sustained ~6.3 MB/frame + ~20 ms/frame while Holtburg town center is in view + +**Status:** OPEN +**Severity:** MEDIUM (halves frame rate and allocates ~300 MB/s while it holds) +**Filed:** 2026-08-23 (found while measuring the #429 fix; NOT caused by it — +reproduces identically on the pre-fix binary) +**Component:** rendering (untracked render path — attribution not yet done) + +**Symptom:** with the player at/near Holtburg town center (observed at cell +`0xA9B40019`; NOT at `0xA9B40036` a few cells away), every frame allocates a +near-constant ~6.3 MB and costs ~20 ms CPU (~45-50 FPS from a ~270 FPS +baseline), indefinitely, with Gen0 at ~6/s. `update_us` ~1.8 ms and +`upload_us` ~0 — the time and allocation sit in the untracked render path +(same measurement seam as #429). The mode begins the frame the view reaches +the spot (after a `/teleloc` there, or immediately at login when parked +there) and held for 80+ s of continuous running in a loop around town. + +**Evidence:** frame-history CSVs + stdout under the 2026-08-23 session +scratchpad (`frame-history-postfix-224749.csv` — healthy 7 ms/20 KB frames +for 20 s until the teleport, then 6.1-6.3 MB/frame for the rest; +`frame-history-postfix-225154.csv` — the mode active from login onward; +`probe-429-223228.out.log` — the SAME tail on the PRE-#429-fix binary). +The #429 owner baseline (spawn `0xA8B4002A`, running loops near-but-not-in +town) never shows it: normal frames ~22 KB at ~247 FPS. + +**Partial attribution + latch behavior (from the #429 probe runs):** the +per-phase lines that crossed the probe's 8 MB print floor split the mode as +a near-constant **~6.0 MB/frame in the PView draw +(`WorldSceneRenderer` → `DrawInside`)** plus an intermittent ~4.26 MB in +the post-world diagnostics phase. Once triggered it LATCHES: a 70 s +straight-line run ~300+ m away from town held EXACTLY ~6,187 KB/frame the +whole way (the town stays inside the Near ring at that distance, so +whatever content drives it stays resident). Trigger observed at town +center `0xA9B40019` but NOT at `0xA9B40036`, and NOT on the owner's +`0xA8B4002A`-spawn loops. Candidate families, unverified: the town's +buildings entering the PView nearby-building/cell set; an animated static +(the windmill, #426-adjacent) keeping a per-frame path hot. Re-add the +#429 attribution probe (one level deeper, inside DrawInside) and measure — +do NOT guess. + +**Gate caution:** a post-#429 measurement run that strays into this latch +shows every frame as a ~20 ms "stall" at ~6.3 MB — that is THIS issue, not +#429 residue. Compare only non-latched segments (normal frames ~22 KB), or +route away from Holtburg town center. + **Next probes (in order):** 1. `ACDREAM_DUMP_MOTION=1` + a temporary inbound-position log for the LOCAL guid: does ACE send position sets for the local player every diff --git a/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs b/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs index 035ffa9f..75e9518c 100644 --- a/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs +++ b/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs @@ -38,7 +38,12 @@ internal readonly record struct DirectionalSunShadowRenderInput( float ResidentMaximumReachMeters = float.PositiveInfinity, bool MeasureGpuTimers = true, bool MeasureCpuStages = false, - AtmosphericFrameBufferBinding AtmosphericFrame = default); + AtmosphericFrameBufferBinding AtmosphericFrame = default, + // #429 owner-approved pipelining: false keeps the retained caster/draw + // topology this frame (transform refresh only) so the rebuild lands on a + // quieter frame. The prepare seams below re-validate and rebuild anyway + // whenever deferral would be unsafe. + bool AllowTopologyRebuild = true); internal readonly record struct DirectionalSunShadowCpuStageTicks( long EnvironmentGateTicks, @@ -394,7 +399,9 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS long cpuStageStarted = input.MeasureCpuStages ? Stopwatch.GetTimestamp() : 0L; DirectionalShadowPreparedDraws worldDraws = - world.PrepareDirectionalShadowDraws(input.Casters); + world.PrepareDirectionalShadowDraws( + input.Casters, + input.AllowTopologyRebuild); DirectionalShadowTerrainPreparedDraws terrainDraws = terrain.PrepareDirectionalShadowDraws(); DirectionalShadowMeshGeometry? worldGeometry = diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs index 1cadfb34..95f7ee1d 100644 --- a/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs +++ b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs @@ -197,6 +197,10 @@ internal sealed class AtmosphericPostProcessGraph : private readonly bool _fuseLowPostProcess; private readonly AtmosphericCpuStageProfiler? _cpuStageProfiler; private readonly DirectionalShadowCasterFrame _shadowCasters = new(); + // #429 owner-approved pipelining state — see RenderDirectionalShadows. + private ulong _lastObservedSceneShadowRevision; + private long _lastObservedAvailabilityVersion; + private int _shadowRebuildDeferrals; private TargetSet? _targets; private AtmosphericFrameInputs _lastInputs; private DirectionalSunShadowDiagnostics _lastShadowDiagnostics; @@ -395,7 +399,33 @@ internal sealed class AtmosphericPostProcessGraph : bool measureCpuStages = _cpuStageProfiler is not null && AtmosphericCpuStageProfiler.ShouldMeasure(frame.Serial); long stageStarted = measureCpuStages ? Stopwatch.GetTimestamp() : 0L; - _shadowCasters.Build(in scene); + // #429 owner-approved pipelining: on a frame where the shadow inputs + // just CHANGED (streaming publish churn — the same frame already pays + // the frame-view and landscape rebuilds), keep the retained shadow + // topology and let the rebuild land on the next quieter frame. Capped + // at two consecutive deferrals: the GPU frame fence is deeper than + // that, so retained prepared draws can never reference an arena range + // that was released AND reused while deferred. A caster rebuild the + // frame forces anyway (first build, generation change, journal + // overflow) re-enables the draws rebuild in the same frame — the + // prepared draws must never index a caster frame they were not built + // from. + ulong sceneShadowRevision = scene.DirectionalShadowTopologyRevision; + long availabilityVersion = worldMeshes.DirectionalShadowAvailabilityVersion; + bool shadowInputsChanged = + sceneShadowRevision != _lastObservedSceneShadowRevision + || availabilityVersion != _lastObservedAvailabilityVersion; + _lastObservedSceneShadowRevision = sceneShadowRevision; + _lastObservedAvailabilityVersion = availabilityVersion; + bool allowTopologyRebuild = + !shadowInputsChanged || _shadowRebuildDeferrals >= 2; + ulong casterSequenceBefore = _shadowCasters.BuildSequence; + _shadowCasters.Build(in scene, allowTopologyRebuild); + if (_shadowCasters.BuildSequence != casterSequenceBefore) + allowTopologyRebuild = true; + _shadowRebuildDeferrals = allowTopologyRebuild + ? 0 + : _shadowRebuildDeferrals + 1; long casterBuildFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L; AuthoredCelestialShadowSource source = world.CelestialShadowSource; var environment = new DirectionalShadowEnvironmentInput( @@ -433,7 +463,8 @@ internal sealed class AtmosphericPostProcessGraph : Preset.Semantic, frame.Serial), MeasureCpuStages: measureCpuStages, - AtmosphericFrame: shadowAtmosphericFrame); + AtmosphericFrame: shadowAtmosphericFrame, + AllowTopologyRebuild: allowTopologyRebuild); long environmentFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L; _lastShadowCasterCount = _shadowCasters.Stats.Accepted; _lastShadowClassificationCalls = _shadowCasters.Stats.TopologyRebuilt ? 1 : 0; diff --git a/src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs b/src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs index e3e77ed8..39077c3d 100644 --- a/src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs +++ b/src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs @@ -103,6 +103,9 @@ internal sealed class DirectionalShadowCasterFrame private RenderProjectionClass[] _casterClasses = []; private RenderProjectionId[] _denseIdScratch = []; private RenderProjectionRecord[] _denseRecordScratch = []; + private int[] _sortIndices = []; + private ulong[] _sortKeys = []; + private DirectionalShadowCaster[] _sortScratch = []; private readonly DirectionalShadowTransformSnapshot[] _transformChangeScratch = new DirectionalShadowTransformSnapshot[ DirectionalShadowTransformChangeJournal.Capacity]; @@ -164,14 +167,34 @@ internal sealed class DirectionalShadowCasterFrame + System.Runtime.CompilerServices.Unsafe.SizeOf< KeyValuePair>())); - public void Build(in RenderSceneQuery query) + /// + /// #429 owner-approved pipelining: with + /// false, a topology-stale frame + /// keeps the retained caster product and only refreshes transforms, so the + /// copy+classify cost moves off the streaming-churn frame that triggered + /// it. The deferral is best-effort: the FIRST build, a generation change, + /// and a transform journal that demands a full refresh (dense re-copy by + /// id would dereference removed scene entries) all rebuild immediately + /// regardless. While deferred, journal rows whose caster identity no + /// longer matches the retained topology are skipped instead of throwing — + /// the immediately following rebuild reconciles them. + /// + public void Build(in RenderSceneQuery query, bool allowTopologyRebuild = true) { ulong topologyRevision = query.DirectionalShadowTopologyRevision; - if (BuildSequence != 0 + bool current = BuildSequence != 0 && Generation == query.Generation - && _topologyRevision == topologyRevision) + && _topologyRevision == topologyRevision; + bool deferStale = !allowTopologyRebuild + && !current + && BuildSequence != 0 + && Generation == query.Generation + && !RefreshRequiresFullCopy(in query); + if (current || deferStale) { - int refreshes = RefreshChangedTransforms(in query); + int refreshes = RefreshChangedTransforms( + in query, + tolerateStaleTopology: deferStale); Stats = Stats with { IndexCopies = 0, @@ -230,11 +253,7 @@ internal sealed class DirectionalShadowCasterFrame for (int i = 0; i < dynamicCount; i++) Add(_outdoorDynamicScratch[i]); - Array.Sort( - _casters, - 0, - _casterCount, - DirectionalShadowCasterComparer.Instance); + SortCasters(); int refreshCasterCount = 0; for (int casterIndex = 0; casterIndex < _casterCount; casterIndex++) { @@ -375,7 +394,26 @@ internal sealed class DirectionalShadowCasterFrame } } - private int RefreshChangedTransforms(in RenderSceneQuery query) + /// + /// Pure pre-check for the deferral gate: would refreshing from the journal + /// demand the dense by-id re-copy? The journal copy is a read; the state + /// consuming it () only advances inside + /// . + /// + private bool RefreshRequiresFullCopy(in RenderSceneQuery query) + { + if (query.DirectionalShadowTransformRevision == _transformRevision) + return false; + DirectionalShadowTransformChanges changes = + query.CopyDirectionalShadowTransformChanges( + _transformRevision, + _transformChangeScratch); + return changes.RequiresFullRefresh; + } + + private int RefreshChangedTransforms( + in RenderSceneQuery query, + bool tolerateStaleTopology = false) { _changedCasterPoseCount = 0; ulong latest = query.DirectionalShadowTransformRevision; @@ -397,6 +435,15 @@ internal sealed class DirectionalShadowCasterFrame _lastTransformChanges = changes; if (changes.RequiresFullRefresh) { + if (tolerateStaleTopology) + { + // Unreachable through Build's deferral gate (it pre-checks via + // RefreshRequiresFullCopy); kept as a hard stop because the + // dense by-id copy below would throw on scene entries the + // stale topology still names. + throw new InvalidOperationException( + "A stale-topology refresh cannot perform the dense full re-copy."); + } _lastBatchedProjectionCopyCalls = 1; for (int index = 0; index < _refreshCasterSlotCount; index++) { @@ -442,6 +489,17 @@ internal sealed class DirectionalShadowCasterFrame { continue; } + if (tolerateStaleTopology + && (records[index].Id != _casterIds[casterIndex] + || records[index].ProjectionClass + != _casterClasses[casterIndex])) + { + // A replaced scene entry (destroy + recreate under a new + // class) can journal against a retained slot while the + // topology rebuild is deferred; the rebuild on the next + // allowed frame reconciles it. + continue; + } _changedCasterFlags[casterIndex] = true; ValidateStablePose(in records[index], casterIndex); _changedCasterPoses[_changedCasterPoseCount++] = @@ -525,6 +583,51 @@ internal sealed class DirectionalShadowCasterFrame Array.Resize(ref values, capacity); } + /// + /// #429 residual-stall fix: same packed-key index sort as + /// DirectionalShadowPreparedDraws.SortSourceDraws. The caster + /// comparer orders by the 64-bit traversal SortKey.Value with the + /// projection id as tie-break, so the key needs no packing at all — + /// almost every pair resolves on one integer compare and the sort swaps + /// 4-byte indices instead of the multi-hundred-byte caster records. + /// Equal keys fall back to the exact comparer plus an index tie-break, + /// preserving the previous total order. + /// + private void SortCasters() + { + int count = _casterCount; + EnsureCapacity(ref _sortIndices, count); + EnsureCapacity(ref _sortKeys, count); + EnsureCapacity(ref _sortScratch, count); + for (int i = 0; i < count; i++) + { + _sortKeys[i] = _casters[i].Projection.SortKey.Value; + _sortIndices[i] = i; + } + _sortIndices.AsSpan(0, count).Sort( + new CasterIndexComparer(_sortKeys, _casters)); + for (int i = 0; i < count; i++) + _sortScratch[i] = _casters[_sortIndices[i]]; + (_casters, _sortScratch) = (_sortScratch, _casters); + } + + private readonly struct CasterIndexComparer( + ulong[] keys, + DirectionalShadowCaster[] casters) : IComparer + { + public int Compare(int x, int y) + { + ulong left = keys[x]; + ulong right = keys[y]; + if (left != right) + return left < right ? -1 : 1; + int order = DirectionalShadowCasterComparer.Instance.Compare( + casters[x], + casters[y]); + return order != 0 ? order : x.CompareTo(y); + } + } + private sealed class DirectionalShadowCasterComparer : IComparer { diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs index 350c73ec..44a497e2 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs @@ -89,6 +89,15 @@ internal sealed class DirectionalShadowPreparedDraws private DrawElementsIndirectCommand[] _commands = []; private DirectionalShadowPreparedBatch[] _batches = []; private DirectionalShadowPreparedRun[] _runs = []; + private int[] _drawNextInGroup = []; + private int[] _groupHead = []; + private int[] _groupTail = []; + private int[] _groupCountByGroup = []; + private ulong[] _groupKeyHi = []; + private ulong[] _groupKeyLo = []; + private int[] _groupFirstDraw = []; + private int[] _groupOrder = []; + private readonly Dictionary _groupByKey = []; private int _sourceCount; private int _commandCount; private int _runCount; @@ -183,7 +192,18 @@ internal sealed class DirectionalShadowPreparedDraws + _mappedCasterIdentityPresent.Length + (long)_commands.Length * Unsafe.SizeOf() + (long)_batches.Length * Unsafe.SizeOf() - + (long)_runs.Length * Unsafe.SizeOf()); + + (long)_runs.Length * Unsafe.SizeOf() + + (long)_drawNextInGroup.Length * sizeof(int) + + (long)_groupHead.Length * sizeof(int) + + (long)_groupTail.Length * sizeof(int) + + (long)_groupCountByGroup.Length * sizeof(int) + + (long)_groupKeyHi.Length * sizeof(ulong) + + (long)_groupKeyLo.Length * sizeof(ulong) + + (long)_groupFirstDraw.Length * sizeof(int) + + (long)_groupOrder.Length * sizeof(int) + + (long)_groupByKey.EnsureCapacity(0) + * (sizeof(int) + + Unsafe.SizeOf>())); /// /// Returns false when this exact resident-caster build was already @@ -358,11 +378,63 @@ internal sealed class DirectionalShadowPreparedDraws if (casterBuildSequence == 0) throw new ArgumentOutOfRangeException(nameof(casterBuildSequence)); - Array.Sort( - _source, - 0, - _sourceCount, - DirectionalShadowSourceDrawComparer.Instance); + // #429 residual-stall fix, round 2. The full draw sort existed only to + // make equal keys contiguous for the grouping walk below — but the + // draw list is ~100k entries while the DISTINCT keys number in the + // low thousands. Hash-group the draws in one O(n) pass (chained + // per-group index lists over retained arrays), then order the GROUPS + // by their packed keys (an O(g log g) sort of small integers). The + // emitted product is identical to the former stable full sort in + // every reachable state: group membership keys on exact DrawKey + // equality via the dictionary; group order follows the same packed + // material | cull | firstIndex | baseVertex (hi) and indexCount | + // slot | layer | foliage (lo) chain with first-appearance as the + // final tie-break; instances within a group keep insertion order, + // exactly as the former index tie-break produced. + int groupCount = 0; + _groupByKey.Clear(); + EnsureCapacity(ref _drawNextInGroup, _sourceCount); + EnsureCapacity(ref _groupHead, _sourceCount); + EnsureCapacity(ref _groupTail, _sourceCount); + EnsureCapacity(ref _groupCountByGroup, _sourceCount); + EnsureCapacity(ref _groupKeyHi, _sourceCount); + EnsureCapacity(ref _groupKeyLo, _sourceCount); + EnsureCapacity(ref _groupFirstDraw, _sourceCount); + EnsureCapacity(ref _groupOrder, _sourceCount); + for (int i = 0; i < _sourceCount; i++) + { + DirectionalShadowDrawKey key = _source[i].Key; + if (!_groupByKey.TryGetValue(key, out int group)) + { + group = groupCount++; + _groupByKey.Add(key, group); + _groupHead[group] = i; + _groupTail[group] = i; + _groupCountByGroup[group] = 0; + _groupFirstDraw[group] = i; + _groupKeyHi[group] = + ((ulong)(byte)key.Material << 62) + | ((ulong)((uint)key.CullMode & 0x3u) << 60) + | ((ulong)key.FirstIndex << 28) + | ((ulong)(uint)key.BaseVertex & 0x0FFF_FFFFul); + _groupKeyLo[group] = + ((ulong)Math.Min((uint)key.IndexCount, 0xF_FFFFu) << 44) + | ((ulong)key.TextureSlot.Index << 12) + | ((ulong)Math.Min(key.TextureLayer, 0x3FFu) << 2) + | (key.FoliageFlags & 0x3u); + } + else + { + _drawNextInGroup[_groupTail[group]] = i; + _groupTail[group] = i; + } + _drawNextInGroup[i] = -1; + _groupCountByGroup[group]++; + } + for (int g = 0; g < groupCount; g++) + _groupOrder[g] = g; + _groupOrder.AsSpan(0, groupCount).Sort( + new GroupOrderComparer(_groupKeyHi, _groupKeyLo, _groupFirstDraw)); EnsureCapacity(ref _transforms, _sourceCount); EnsureCapacity(ref _transformSources, _sourceCount); EnsureCapacity(ref _dynamicTransformSlots, _sourceCount); @@ -395,24 +467,24 @@ internal sealed class DirectionalShadowPreparedDraws _mappedCasterCount); } - int sourceIndex = 0; int transformIndex = 0; int commandIndex = 0; int opaqueCommands = 0; - while (sourceIndex < _sourceCount) + for (int orderIndex = 0; orderIndex < groupCount; orderIndex++) { - DirectionalShadowDrawKey key = _source[sourceIndex].Key; - int groupStart = sourceIndex; - do + int group = _groupOrder[orderIndex]; + DirectionalShadowDrawKey key = _source[_groupFirstDraw[group]].Key; + int instanceCount = _groupCountByGroup[group]; + for (int draw = _groupHead[group]; draw >= 0; draw = _drawNextInGroup[draw]) { - _transforms[transformIndex++] = _source[sourceIndex].Transform; + _transforms[transformIndex++] = _source[draw].Transform; _transformSources[transformIndex - 1] = - _source[sourceIndex].TransformSource; - if (_source[sourceIndex].TransformSource.Refreshable) + _source[draw].TransformSource; + if (_source[draw].TransformSource.Refreshable) { int dynamicTransformIndex = transformIndex - 1; DirectionalShadowTransformSource transformSource = - _source[sourceIndex].TransformSource; + _source[draw].TransformSource; if (transformSource.CasterIndex < 0) { throw new InvalidOperationException( @@ -425,11 +497,8 @@ internal sealed class DirectionalShadowPreparedDraws _firstDynamicTransformByCaster[transformSource.CasterIndex] = dynamicTransformIndex; } - sourceIndex++; } - while (sourceIndex < _sourceCount && _source[sourceIndex].Key == key); - int instanceCount = sourceIndex - groupStart; _commands[commandIndex] = new DrawElementsIndirectCommand { Count = checked((uint)key.IndexCount), @@ -863,6 +932,35 @@ internal sealed class DirectionalShadowPreparedDraws Array.Resize(ref values, capacity); } + /// + /// Orders the hash-built groups for emission: the packed hi key carries + /// Material (2) | CullMode (2) | FirstIndex (32) | BaseVertex low 28, the + /// lo key IndexCount (20, clamped) | TextureSlot (32) | TextureLayer (10, + /// clamped) | FoliageFlags (2) — the exact field chain the former full + /// draw sort compared — with first-appearance as the final deterministic + /// tie-break. Clamp collisions (unreachable with the configured arena and + /// atlas maxima) can only reorder whole groups inside one material+cull + /// run; group membership itself keys on exact DrawKey equality. + /// + private readonly struct GroupOrderComparer( + ulong[] keyHi, + ulong[] keyLo, + int[] firstDraw) : IComparer + { + public int Compare(int x, int y) + { + ulong left = keyHi[x]; + ulong right = keyHi[y]; + if (left != right) + return left < right ? -1 : 1; + left = keyLo[x]; + right = keyLo[y]; + if (left != right) + return left < right ? -1 : 1; + return firstDraw[x].CompareTo(firstDraw[y]); + } + } + private readonly record struct DirectionalShadowDrawKey( uint FirstIndex, int BaseVertex, @@ -881,42 +979,15 @@ internal sealed class DirectionalShadowPreparedDraws Matrix4x4 Transform, DirectionalShadowTransformSource TransformSource); - private sealed class DirectionalShadowSourceDrawComparer - : IComparer - { - public static DirectionalShadowSourceDrawComparer Instance { get; } = new(); - - public int Compare( - DirectionalShadowSourceDraw left, - DirectionalShadowSourceDraw right) - { - DirectionalShadowDrawKey x = left.Key; - DirectionalShadowDrawKey y = right.Key; - int order = x.Material.CompareTo(y.Material); - if (order != 0) return order; - order = x.CullMode.CompareTo(y.CullMode); - if (order != 0) return order; - order = x.FirstIndex.CompareTo(y.FirstIndex); - if (order != 0) return order; - order = x.BaseVertex.CompareTo(y.BaseVertex); - if (order != 0) return order; - order = x.IndexCount.CompareTo(y.IndexCount); - if (order != 0) return order; - order = x.TextureSlot.Index.CompareTo(y.TextureSlot.Index); - if (order != 0) return order; - order = x.TextureLayer.CompareTo(y.TextureLayer); - // Campaign VM VM6: tie-break on FoliageFlags so entries sharing - // every other key field but differing only in classification - // (the rare case a mesh subset is reachable from both a - // procedural-scenery and a non-scenery placement) still sort - // into one contiguous, exact-key-matched run instead of an - // unstable-sort-dependent scatter. The grouping loop below keys - // on exact DirectionalShadowDrawKey equality regardless. - return order != 0 - ? order - : x.FoliageFlags.CompareTo(y.FoliageFlags); - } - } + // The former full-draw sort comparer is gone with the sort itself. + // #429 postmortem, preserved here because the lesson is easy to lose: + // its original `x.Material.CompareTo(y.Material)` bound to + // Enum.CompareTo(object) and boxed BOTH operands on every comparison — + // measured at 38.9 MB of garbage per topology rebuild (~4M boxes across + // the N·log N sort), rebuilt on every streaming-churn frame while the + // player moves. Compare enums through their underlying integers, or + // better, do not sort 100k draws when hash-grouping plus a small + // group-key sort produces the identical product (see Complete). } public sealed partial class WbDrawDispatcher @@ -939,8 +1010,17 @@ public sealed partial class WbDrawDispatcher /// returned owner is renderer-retained and remains valid until the next /// distinct caster build is prepared. /// + /// + /// #429: the version the shadow pipelining policy observes — the same + /// counter keys its topology + /// gate on. + /// + internal long DirectionalShadowAvailabilityVersion => + _meshAdapter.MeshManager?.RenderDataAvailabilityVersion ?? 0L; + internal DirectionalShadowPreparedDraws PrepareDirectionalShadowDraws( - DirectionalShadowCasterFrame casters) + DirectionalShadowCasterFrame casters, + bool allowTopologyRebuild = true) { ArgumentNullException.ThrowIfNull(casters); ReadOnlySpan source = casters.Casters; @@ -956,6 +1036,19 @@ public sealed partial class WbDrawDispatcher _directionalShadowDraws.RefreshDynamicTransforms(casters); return _directionalShadowDraws; } + // #429 owner-approved pipelining: a deferred frame keeps the retained + // prepared draws and only refreshes transforms — valid ONLY while the + // product was built from this exact caster frame; a caster rebuild or + // generation change invalidates the caster-slot mapping the transform + // refresh indexes by, so those rebuild immediately regardless. + if (!allowTopologyRebuild + && _directionalShadowDraws.SourceCasterBuildSequence + == casters.BuildSequence + && _directionalShadowDraws.SourceGeneration == casters.Generation) + { + _directionalShadowDraws.RefreshDynamicTransforms(casters); + return _directionalShadowDraws; + } int estimatedInstances = 0; for (int i = 0; i < source.Length; i++) diff --git a/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowPreparedDrawTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowPreparedDrawTests.cs index 681c7ac4..9dd73a00 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowPreparedDrawTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowPreparedDrawTests.cs @@ -598,4 +598,87 @@ public sealed class DirectionalShadowPreparedDrawTests MemoryMarshal.CreateReadOnlySpan(ref actual, 1)); Assert.True(expectedBits.SequenceEqual(actualBits)); } + + /// + /// #429 allocation gate (I1 style). A warmed topology rebuild owns every + /// retained buffer it needs, so the whole + /// TryBegin → Add×N → Complete transaction must allocate near zero. + /// The regression this pins: the Complete sort's comparer used + /// enum.CompareTo(enum), which binds to + /// Enum.CompareTo(object) and boxes BOTH operands on every + /// comparison — measured at 38.9 MB of garbage per rebuild in a + /// production window (~4M boxes across the N·log N sort), rebuilt on + /// every streaming-churn frame while the player moves. That was the + /// #429 run-hitch. + /// + [Fact] + public void AWarmedTopologyRebuildAllocatesNearZero() + { + const int drawCount = 4096; + var product = new DirectionalShadowPreparedDraws(); + RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(9); + + BuildVariedTopology(product, generation, buildSequence: 1, drawCount); + long before = GC.GetAllocatedBytesForCurrentThread(); + BuildVariedTopology(product, generation, buildSequence: 2, drawCount); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(drawCount, product.Stats.PreparedInstances); + // One small constant covers the sort's comparison-delegate wrapper. + // The boxing regression allocates ~2 MB at this draw count and fails + // this gate by three orders of magnitude. + Assert.True( + allocated < 2048, + $"A warmed directional-shadow topology rebuild allocated {allocated} bytes."); + } + + private static void BuildVariedTopology( + DirectionalShadowPreparedDraws product, + RenderSceneGeneration generation, + ulong buildSequence, + int drawCount) + { + Assert.True(product.TryBegin( + generation, + buildSequence, + estimatedInstances: drawCount)); + Matrix4x4 transform = Matrix4x4.Identity; + for (int i = 0; i < drawCount; i++) + { + // Vary every sort-key dimension so Complete's sort exercises the + // full comparison chain (material, cull mode, then the integers). + bool cutout = (i & 1) != 0; + product.Add( + firstIndex: (uint)((i * 37) % 1024), + baseVertex: (i * 13) % 512, + indexCount: 3 + (i % 5) * 3, + cutout ? new GpuTextureSlot((uint)(i % 7)) : GpuTextureSlot.Unassigned, + textureLayer: (uint)(i % 11), + (i % 3) switch + { + 0 => CullMode.None, + 1 => CullMode.Clockwise, + _ => CullMode.CounterClockwise, + }, + cutout + ? DirectionalShadowCasterMaterial.AlphaCutout + : DirectionalShadowCasterMaterial.Opaque, + in transform); + } + product.Complete( + generation, + buildSequence, + new DirectionalShadowPreparationStats( + SourceCasters: drawCount, + SourceMeshRefs: drawCount, + SourceParts: drawCount, + SourceBatches: drawCount, + PreparedInstances: 0, + PreparedOpaqueCommands: 0, + PreparedAlphaCutoutCommands: 0, + RejectedTransparentBatches: 0, + RejectedFadedParts: 0, + MissingMeshes: 0, + UnresolvedAlphaCutoutTextures: 0)); + } } From b217a292bb6079e9390338575c70911597ba7eed Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 09:17:32 +0200 Subject: [PATCH 05/89] =?UTF-8?q?docs:=20close=20#429=20in=20the=20issue?= =?UTF-8?q?=20ledger=20=E2=80=94=20owner-accepted=20both=20modes,=20fix=20?= =?UTF-8?q?SHAs=20recorded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 97c45dbc..78b7453d 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -74,9 +74,18 @@ resolution (SkillTable/attribute-formula) before porting any recompute. --- -## #429 — Periodic hitch in the LOCAL player's own motion while running (~2-3 s cadence) +## #429 — CLOSED: Periodic hitch in the LOCAL player's own motion while running (~2-3 s cadence) -**Status:** OPEN +**Status:** CLOSED 2026-08-24 — owner-accepted in both presentation modes +("Feels good capped too"). Fixed across three commits: `0330fcd0` +(allocation-exact streamed-mesh completion), `4873c106` (presented +player + chase camera share the object clock), `ad695589` +(allocation-free shadow topology rebuild + churn-frame pipelining). +Final measured state: stall frames 5.8/s → ~0.45/s uncapped (0.49/s +capped), median stall 20.3 → 13.7 ms, camera-vs-player decoherence +~1 m → 0.2–1.2 cm median. Residual content-proportional rebuild cost is +the incremental-topology successor campaign; the town-view latch found +during measurement is #432. **Severity:** MEDIUM (noticeable during ordinary play) **Filed:** 2026-08-23 (owner report during the night-sky session) **Component:** movement / prediction / server reconciliation From 92999b01016f47b033c48e28d4160af7e2b0a047 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 10:10:46 +0200 Subject: [PATCH 06/89] =?UTF-8?q?fix=20#432:=20allocation-free=20oracle=20?= =?UTF-8?q?fingerprint=20sort=20=E2=80=94=20the=20~6=20MB/frame=20diagnost?= =?UTF-8?q?ics=20tax?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second instance of the #429 (ad695589) boxing-comparer defect class, this time in the measurement harness rather than production: ACDREAM_AUTOMATION_ARTIFACT_DIR (with retained-UI screenshots) constructs CurrentRenderSceneOracle, whose presence as partition observer re-enables the G5-retired legacy InteriorEntityPartition every frame with per-entity fingerprinting. Complete() then sorts one fingerprint per RESIDENT entity (~60k across the streaming window), and the comparer's first key bound x.ProjectionClass.CompareTo(y.ProjectionClass) to Enum.CompareTo(object), boxing both operands. The 3-value enum almost always ties, so the boxing ran on essentially every comparison: a measured ~6.2 MB and ~14 ms per frame, everywhere — not town-specific and not view-triggered, which is also why it appeared to "latch" (the resident set drives it, not the view). Comparing the underlying integral value keeps the identical order. Hermetic gate: one warmed observed partition of 20,000 entities allocated 15,876,088 bytes before, and passes a <64 KiB bound after (OracleObservedPartitionAllocationTests). Ordinary play never constructs the oracle, so no player-visible behavior changes; what changes is that captures taken with the automation artifact directory set are no longer taxed. The #429 acceptance data is unaffected (owner drives and the deciding A/B arms ran with the artifact dir null). The temporary [pview-alloc] attribution probe that localized this is retired in the same commit; the gate test now guards the defect. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 124 +++++++++++++++--- .../Scene/CurrentRenderSceneOracle.cs | 9 +- .../OracleObservedPartitionAllocationTests.cs | 65 +++++++++ 3 files changed, 180 insertions(+), 18 deletions(-) create mode 100644 tests/AcDream.App.Tests/Rendering/OracleObservedPartitionAllocationTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 78b7453d..80164fc7 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,45 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #433 — Stale entities from OTHER landblocks visible ("hanging in the air") after portal travel or /ls near Holtburg + +**Status:** OPEN +**Severity:** MEDIUM (visibly wrong world state after ordinary travel) +**Filed:** 2026-08-24 (owner report) +**Component:** entity lifecycle / landblock retirement / reveal generation + +**Symptom (owner):** sometimes, after portaling around or using `/ls` +(lifestone recall) while close to Holtburg, monsters and other objects +from OTHER landblocks are visible hanging in the air — as if the old +world's entities were never flushed. Intermittent; "sometimes." + +**Relationship to #432:** NOT its cause — #432 reproduces on a fresh +login parked at town center with no prior travel. But both may share a +residency root (content near Holtburg staying resident/drawn when it +should be retired). The #432 DrawInside attribution probe should also +report WHOSE entities/cells the per-frame cost walks; if dead-generation +leftovers appear there, merge the investigations. + +**Where to look first:** the J6.2 canonical reveal-generation owner +(Runtime owns the sole reveal generation and old-world quiescence — +`docs/plans/2026-07-24-modern-runtime-architecture.md` Slice E/J6.2: +"generation-scoped old-world quiescence"), the entity teardown path at +generation reset (`RuntimeEntityDirectory` tombstones), and the +presentation sidecars (`LiveEntityProjectionStore`) — an entity drawn +without its landblock means the graphical sidecar outlived either its +runtime record or its cell residency. Distinguish: (a) runtime entity +alive but should be dead (server never sent destroy / we dropped it), +(b) runtime dead but presentation sidecar leaked, (c) entity correctly +alive but its OWN landblock geometry retired while inside the Far ring. +"Hanging in the air" + "other landblocks" suggests (b) or (c). + +**Repro lead:** portal arrivals and /ls near Holtburg; intermittent. +Capture: `ACDREAM_PROBE_CELL=1` + entity-ledger counts at the reveal +transition; a screenshot naming one floating guid would pin (a) vs (b) +immediately (F2 overlay shows guids). + +--- + ## #430 — No tooltips on skills and attributes in the character panel **Status:** OPEN @@ -409,7 +448,64 @@ transient parallel-load flake observed once, known-flake class). ## #432 — Sustained ~6.3 MB/frame + ~20 ms/frame while Holtburg town center is in view -**Status:** OPEN +**Status:** ROOT-CAUSED 2026-08-24 — NOT a production bug; a +measurement-harness mode. Fix verified in a hermetic gate, awaiting +commit approval. **Reclassified:** the mode is neither town-specific nor +view-triggered — it is active in ANY diagnostics-instrumented run, +scaled by the resident entity count of the whole streaming window. + +**Root cause (three links, each verified):** +1. `ACDREAM_AUTOMATION_ARTIFACT_DIR` (+ retained-UI screenshots) + constructs `CurrentRenderSceneOracle` + (`FrameRootComposition.cs:349`). Ordinary play never constructs it — + the owner was never affected. +2. The oracle's presence as partition observer flips + `LegacyPartitionDiagnosticsEnabled` + (`RetailPViewRenderer.cs`), so the G5-retired legacy + `InteriorEntityPartition` runs EVERY frame with per-entity + fingerprint observation. +3. `CurrentRenderSceneOracle.Complete` sorts the per-frame fingerprints + of every resident entity; the comparer's first key compared enums via + `x.ProjectionClass.CompareTo(y.ProjectionClass)` → + `Enum.CompareTo(object)` → boxes BOTH operands per comparison. The + 3-value enum almost always ties, so the boxing executes on + essentially every one of the sort's ~n·log n comparisons — the #429 + `ad695589` boxing-comparer defect class, second instance. + +**Evidence:** [pview-alloc] per-phase probe (`ACDREAM_PROBE_PVIEW_ALLOC=1`, +TEMPORARY, in `RetailPViewRenderer.DrawInside`) attributes the steady +mode to the partition phase: `part=6199KB` at the login spot +(`0xABB20030`) AND `part=6189KB` at town (`0xA9B4001E`) — near +content-independent because the resident far-window entity total is +similar (~60k) in both areas; this also explains the "latch" (the +resident set, not the view, drives it). Natural experiment across the +2026-08-23/24 runs, same town route: artifact-dir NULL runs +(`d2-packon/packoff`) average 48–67 KB/frame with ~50–74 churn frames +>3 MB out of ~18–20k; artifact-dir SET runs average 2.3–4.6 MB/frame +with thousands. Hermetic repro +(`OracleObservedPartitionAllocationTests.AWarmedObservedPartitionAllocatesNearZero`): +one warmed observed partition of 20,000 entities allocated +**15,876,088 bytes**; with the one-line non-boxing compare +(`((int)x.ProjectionClass).CompareTo((int)y.ProjectionClass)`) it passes +the <64 KB gate. + +**Consequences for past measurements:** any capture taken with +`ACDREAM_AUTOMATION_ARTIFACT_DIR` set carries this ~6 MB/frame + ~14 ms +diagnostic tax. The #429 acceptance data is CLEAN — the owner drives and +the A/B + d2 arms ran with the artifact dir null. + +**Not related to #433:** the stale-entity sighting happens in ordinary +play (no artifact dir), so it cannot share this mechanism. + +**Remaining (with the fix):** the oracle still costs real CPU per frame +(fingerprint walk + sort over ~60k entities) even allocation-free — +acceptable for a diagnostics-only path, but automation-gate FPS numbers +remain diagnostics-loaded; compare like-for-like only. The intermittent +~4.26 MB "post-world diagnostics" satellite (RenderSceneShadow +comparison, same construction condition) was not separately chased — +re-measure after this fix lands and file separately if it survives. + +**Previous (superseded) framing follows for the record:** **Severity:** MEDIUM (halves frame rate and allocates ~300 MB/s while it holds) **Filed:** 2026-08-23 (found while measuring the #429 fix; NOT caused by it — reproduces identically on the pre-fix binary) @@ -452,21 +548,15 @@ shows every frame as a ~20 ms "stall" at ~6.3 MB — that is THIS issue, not #429 residue. Compare only non-latched segments (normal frames ~22 KB), or route away from Holtburg town center. -**Next probes (in order):** -1. `ACDREAM_DUMP_MOTION=1` + a temporary inbound-position log for the - LOCAL guid: does ACE send position sets for the local player every - ~2-3 s while running, and does acdream apply them? (Retail ignores - routine UpdatePosition for the autonomous local player; only - ForcePosition snaps — verify our projection split here.) -2. Compare `PlayerWeenie.InqRunRate(15230)` against ACE's computed - runRate for the same skill — a formula delta is the root cause if the - correction traffic confirms. -3. If no corrections arrive: instrument the local movement controller's - own periodic state (autorun latch, tracker send side-effects). - -**START at** `claude-memory/project_physics_collision_digest.md` (per-cell -DO-NOT-RETRY rules) and `claude-memory/project_retail_motion_outbound.md` -(TS-33 AutoPos tracker semantics) before instrumenting. +**Next probes:** re-add the #429-style per-phase allocation probe ONE +level deeper — inside the PView draw (`WorldSceneRenderer` → +`DrawInside`): per-cell / per-stage bytes with identity (cell id, entity +guid, draw family), so the ~6.0 MB/frame names its owner instead of the +whole pass. Report WHOSE cells/entities the walk touches (also serves +the #433 stale-entity question). Measure at the trigger cell +`0xA9B40019` vs the clean `0xA9B40036`. (An earlier revision of this +entry carried #429's disproven run-rate/UpdatePosition probe list here — +removed; that theory died with `ca4bae77`.) --- @@ -698,7 +788,7 @@ kinds. Same class as the VM6 fix; do not guess the values. ## #422 — Intermittent heap-corruption exit (0xC0000374) at process exit after an offline capture (pack on OR off) -**Status:** OPEN — filed 2026-08-22 at Campaign VM VM3; **characterised at VM7 (2026-08-23) — rare, pack-INDEPENDENT, exit-time, not yet caught with a stack.** Facts: (1) it fired once more, on the **retail (pack-off)** row of `tools/run-atmospheric-performance-matrix.ps1` at 1920×1080 / 45 s warm-up / uncapped on `621a0edf` — the first launch after a fresh build — so "retail/off never reproduced it" is withdrawn and the title's "pack-on" is wrong: the exit is in the common teardown. (2) It did not fire in 16 runs with cdb attached (High, 720p, 12 s), 24 runs launched under cdb's debug heap (High, 720p, 12 s), 6 runs under the debug heap with the exact matrix recipe, or 10 plain runs with the exact recipe and a forced non-incremental rebuild before run 1 (`tools/i422/loop-debugheap.ps1`, `tools/i422/loop-plain.ps1`) — 1 in ~57 offline runs today, ~2 %. (3) The fail-fast leaves NO Application event-log entry and NO WER report on this machine (WerSvc is in its normal on-demand state, nothing disabled), so there is no dump to read; a per-user `HKCU\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\AcDream.App.exe` key (`DumpType=2`, `DumpFolder`) is the one-time user action that turns the next occurrence into a full dump — the project does not set registry keys itself. (4) The pre-campaign binary (`6c79d35c` + VM0 patches) CANNOT be tested with this tool: it predates the gate's in-process close verb, so every run ends in the gate's forced kill and never reaches the graceful-exit path where the fault lives (10/10 "automation close timed out") — whether the fault predates Campaign AR is therefore unknown, not disproven. Evidence: `docs/research/evidence/vm7/i422-*.txt`, `artifacts/vm7-matrix/uncapped-retail-1920x1080/` (the crashing run's log — managed shutdown complete, `MossTank disabled` last). **Owner decision 2026-08-23: accepted as carried; Campaign VM merged with it open.** Next step when it recurs: the LocalDumps key above, then `tools/i422/loop-plain.ps1` / `loop-debugheap.ps1`. +**Status:** OPEN — filed 2026-08-22 at Campaign VM VM3; **characterised at VM7 (2026-08-23) — rare, pack-INDEPENDENT, exit-time, not yet caught with a stack.** Facts: (1) it fired once more, on the **retail (pack-off)** row of `tools/run-atmospheric-performance-matrix.ps1` at 1920×1080 / 45 s warm-up / uncapped on `621a0edf` — the first launch after a fresh build — so "retail/off never reproduced it" is withdrawn and the title's "pack-on" is wrong: the exit is in the common teardown. (2) It did not fire in 16 runs with cdb attached (High, 720p, 12 s), 24 runs launched under cdb's debug heap (High, 720p, 12 s), 6 runs under the debug heap with the exact matrix recipe, or 10 plain runs with the exact recipe and a forced non-incremental rebuild before run 1 (`tools/i422/loop-debugheap.ps1`, `tools/i422/loop-plain.ps1`) — 1 in ~57 offline runs today, ~2 %. (3) The fail-fast leaves NO Application event-log entry and NO WER report on this machine (WerSvc is in its normal on-demand state, nothing disabled), so there is no dump to read; a per-user `HKCU\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\AcDream.App.exe` key (`DumpType=2`, `DumpFolder`) is the one-time user action that turns the next occurrence into a full dump — the project does not set registry keys itself. (4) The pre-campaign binary (`6c79d35c` + VM0 patches) CANNOT be tested with this tool: it predates the gate's in-process close verb, so every run ends in the gate's forced kill and never reaches the graceful-exit path where the fault lives (10/10 "automation close timed out") — whether the fault predates Campaign AR is therefore unknown, not disproven. Evidence: `docs/research/evidence/vm7/i422-*.txt`, `artifacts/vm7-matrix/uncapped-retail-1920x1080/` (the crashing run's log — managed shutdown complete, `MossTank disabled` last). **Owner decision 2026-08-23: accepted as carried; Campaign VM merged with it open.** Next step when it recurs: the LocalDumps key above, then `tools/i422/loop-plain.ps1` / `loop-debugheap.ps1`. **Recurred 2026-08-24:** the #432 attribution run (`probe-432-094728`, connected live session, graceful WM_CLOSE, managed shutdown complete) exited `-1073740940` — third sighting, first on a CONNECTED (non-offline-capture) run; still no dump (the LocalDumps key remains unset). One earlier #429-round sighting was also at graceful close (~1 in 10 diagnostic runs that day). **Component:** rendering / render packs (Campaign AR) — native teardown **Description:** `tools/run-offline-pixel-gate.ps1 -RenderPackPreset high` (shipped diff --git a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs index 5758a9d5..3b339bd3 100644 --- a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs +++ b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs @@ -1023,7 +1023,14 @@ internal sealed class CurrentRenderSceneOracle : CurrentRenderProjectionFingerprint x, CurrentRenderProjectionFingerprint y) { - int value = x.ProjectionClass.CompareTo(y.ProjectionClass); + // enum.CompareTo(enum) binds to Enum.CompareTo(object) and boxes + // BOTH operands per comparison; ProjectionClass has 3 values so + // this first key almost always ties and the boxing executes on + // essentially every comparison of the per-frame fingerprint sort + // (#432: ~6.2 MB/frame across the resident entity set whenever + // the automation-artifact oracle is constructed). Compare the + // underlying integral value instead — same order, no boxing. + int value = ((int)x.ProjectionClass).CompareTo((int)y.ProjectionClass); if (value != 0) return value; value = x.LandblockId.CompareTo(y.LandblockId); if (value != 0) return value; diff --git a/tests/AcDream.App.Tests/Rendering/OracleObservedPartitionAllocationTests.cs b/tests/AcDream.App.Tests/Rendering/OracleObservedPartitionAllocationTests.cs new file mode 100644 index 00000000..3ec93197 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/OracleObservedPartitionAllocationTests.cs @@ -0,0 +1,65 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Scene; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Rendering; + +public sealed class OracleObservedPartitionAllocationTests +{ + // #432: the automation-artifact harness constructs CurrentRenderSceneOracle, + // which re-enables the G5-retired legacy partition every frame WITH the + // oracle observing every resident entity. A warmed observed partition must + // not allocate per frame — the fingerprint walk and its sort run over the + // whole streaming window's resident entity set (~60k live), so any + // per-comparison or per-entity transient multiplies into MB/frame and + // poisons every measurement taken with ACDREAM_AUTOMATION_ARTIFACT_DIR set. + [Fact] + internal void AWarmedObservedPartitionAllocatesNearZero() + { + const int EntityCount = 20_000; + var entities = new List(EntityCount); + for (int i = 0; i < EntityCount; i++) + { + // Scramble ids so the fingerprint sort does real work instead of + // consuming pre-sorted input. + uint id = unchecked((uint)i * 2654435761u); + entities.Add(new WorldEntity + { + Id = id, + SourceGfxObjOrSetupId = 0x01000000u + (id & 0xFFFu), + Position = new Vector3(i % 192, i / 192f, 0f), + Rotation = Quaternion.Identity, + MeshRefs = new[] { new MeshRef(0x01000001u, Matrix4x4.Identity) }, + }); + } + + var landblockEntries = new[] + { + (LandblockId: 0xA9B4FFFFu, + AabbMin: Vector3.Zero, + AabbMax: new Vector3(192f, 192f, 100f), + Entities: (IReadOnlyList)entities, + AnimatedById: (IReadOnlyDictionary?)null), + }; + var visibleCells = new HashSet(); + var result = new InteriorEntityPartition.Result(); + var oracle = new CurrentRenderSceneOracle(); + + for (int warm = 0; warm < 3; warm++) + { + InteriorEntityPartition.Partition( + result, visibleCells, landblockEntries, oracle); + } + + long before = GC.GetAllocatedBytesForCurrentThread(); + InteriorEntityPartition.Partition( + result, visibleCells, landblockEntries, oracle); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.True( + allocated < 64 * 1024, + $"warmed observed partition allocated {allocated} bytes for " + + $"{EntityCount} entities"); + } +} From e77dd7c413e10517b89d9e3b367fd4177c9de354 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 10:28:59 +0200 Subject: [PATCH 07/89] docs: launch-options reference + the test that keeps it honest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client reads 161 ACDREAM_* environment variables across 79 files. Only about 25 were written down, and the audit found the documentation drifting in both directions: CLAUDE.md still advertised ACDREAM_RUN_SKILL / ACDREAM_JUMP_SKILL (deleted; skills are server-authoritative now, and the jump fallback is 300, not the documented 200), while flags with real side effects had no description at all. docs/launch-options.md documents every one by lifecycle — production, command line, measurement, automation, permanent diagnostics, temporary probes, deprecated, retired — with a mandatory side-effects column. That column is the point: #432 cost three days of taxed measurements because ACDREAM_AUTOMATION_ARTIFACT_DIR reads like an output path and also builds a per-frame diagnostics referee, and ACDREAM_STREAM_RADIUS silently measures a streaming window production never uses. Rows now say so. Other surprises the audit surfaced and recorded: ACDREAM_DUMP_SCENERY_Z swaps in a duplicate scenery-placement path rather than only logging, ACDREAM_PROBE_VIS silently also enables ACDREAM_PROBE_ENVCELL, and ACDREAM_DUMP_ENTITY's id list doubles as an unrelated probe's watchlist. LaunchOptionsDocumentationTests enforces it, because a hand-maintained list of 161 flags is stale within a week: an undocumented flag fails, and so does a documented row whose read site was deleted. It scans string literals rather than GetEnvironmentVariable call shapes — the startup path reads through an injected delegate, so a call-shaped pattern silently missed ACDREAM_LIVE, ACDREAM_PAK_PATH and every other production flag. A third test freezes per-file direct-read debt by exact count (20 files outside the owner classes), so structure rules 4 and 5 can be paid down but not regressed. CLAUDE.md's 94-line env-var section becomes a 16-line pointer, and its stale test-character paragraph is corrected. Also fixed, all doc-vs-code mismatches the audit proved: - RenderingDiagnostics.FrameProfEnabled described a GPU-query self-disable that Campaign V slice V11 deleted. - Two comments named ACDREAM_RENDER_BACKEND as a live co-requisite; it died with the OpenGL backend. - EnvCellRenderer.CollectCellAuditLines and its ACDREAM_A8_AUDIT doc: the method had no caller anywhere and its documented caller never existed. Filed rather than fixed, to keep this a documentation change: #434 (the DebugPanel/DebugVM surface is never constructed, so ~40 "runtime-toggleable" comments are false and 35 env reads are unreachable) and #435 (17 temporary probes outlived their closed investigations; 14 more name no owner). Full hermetic suite 12,202 passed / 0 failed. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 125 ++---- docs/ISSUES.md | 89 +++++ docs/README.md | 6 + docs/launch-options.md | 362 ++++++++++++++++++ .../Rendering/Gpu/Vk/VulkanBringUpHost.cs | 6 +- .../Rendering/Wb/EnvCellRenderer.cs | 55 --- src/AcDream.App/RuntimeOptions.cs | 11 +- .../Rendering/RenderingDiagnostics.cs | 10 +- .../LaunchOptionsDocumentationTests.cs | 245 ++++++++++++ 9 files changed, 744 insertions(+), 165 deletions(-) create mode 100644 docs/launch-options.md create mode 100644 tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index d99f6db2..808da717 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1555,108 +1555,35 @@ governed by whether the previous shutdown was graceful or forced. ### Test character `+Acdream` at server guid `0x5000000A`. Starts at or near Holtburg. Has -basic stats; `ACDREAM_RUN_SKILL` / `ACDREAM_JUMP_SKILL` env vars (default -200) set the *client-side* skill value used by `PlayerWeenie.InqRunRate` -for local motion prediction. **These are NOT synced to the server** — -ACE's own character data is authoritative for broadcast motion. If you -see a speed/anim mismatch between local and observer views, the fix is -to sync the runSkill from ACE via `UpdateMotion.ForwardSpeed` echo (wired -via `PlayerMovementController.ApplyServerRunRate`) or from -`PlayerDescription (0x0013)`. +basic stats. Run/jump skills arrive FROM the server and drive local motion +prediction (`LiveMovementStatsApplier` → `PlayerMovementController`); the +hardcoded fallbacks before the server speaks are 200 run / 300 jump. The +former `ACDREAM_RUN_SKILL` / `ACDREAM_JUMP_SKILL` client-side overrides no +longer exist — see the Retired section of +[`docs/launch-options.md`](docs/launch-options.md). If you see a speed/anim +mismatch between local and observer views, check the server sync path +(`UpdateMotion.ForwardSpeed` echo via +`PlayerMovementController.ApplyServerRunRate`, or +`PlayerDescription (0x0013)`). ### Diagnostic env vars -- `ACDREAM_DUMP_MOTION=1` — dump every inbound `UpdateMotion` (guid, - stance, cmd, speed) + resulting `SetCycle` call. Massive for remote- - animation debugging. -- `ACDREAM_STREAM_RADIUS=N` — **legacy** streaming-radius override - (`RuntimeOptions.LegacyStreamRadius`). **Default is UNSET**, not 2: the - shipped radii come from the quality preset - (`QualityPreset.High` = NearRadius 4 / FarRadius 12, i.e. a 9×9 Near ring - inside a 25×25 Far window). When set it FORCES `NearRadius = N` and only - ever RAISES `FarRadius` (`SessionPlayerComposition.ComposeCore`), and it is - silently discarded by any later Settings `ApplyQuality` - (`RuntimeSettingsTargets.ApplyQuality` → `ReconfigureRadii`). **Leave it - unset for any measurement or gate run** — with it set you are measuring a - different window than production. Per-axis overrides - `ACDREAM_NEAR_RADIUS` / `ACDREAM_FAR_RADIUS` (`QualitySettings.WithEnvOverrides`) - are the modern spelling. -- `ACDREAM_PROBE_REVEAL_RADIUS=N` — #280 A/B measurement probe - (`StreamingDiagnostics.RevealRadiusOverride`). Forces the outdoor reveal - gate to landblock radius N instead of the derived streaming window, so the - same binary can run a route once with the pre-#280 behaviour (`=1`) and once - without. Not a user setting; not surfaced in Settings; not persisted. - Values below 1 are rejected by the parser: an outdoor acknowledgement with - `RequiredRenderRadius == 0` fails Runtime's `invalid-readiness-shape` - invariant, so `=0` would hang the route it is meant to measure. -- `ACDREAM_NO_AUDIO=1` — suppress OpenAL init for headless / driver- - broken setups. -- `ACDREAM_REMOTE_VEL_DIAG=1` — dump per-tick / per-UM remote motion - diagnostics (`[UM_RAW]`, `[SCFAST]`, `[SCFULL]`, `[SETCYCLE]`, - `[FWD_WIRE]`, `[OMEGA_DIAG]`, `[SEQSTATE]`, `[PARTSDIAG]`, - `[VEL_DIAG]`, `[UPCYCLE]`). Heavy. -- `ACDREAM_PROBE_RESOLVE=1` — one `[resolve]` line per - `PhysicsEngine.ResolveWithTransition` call: input + target + output - position/cell, ok-vs-partial, grounded-in, contact-plane status, - wall normal if hit, **responsible entity guid**, env flag, walkable - polygon valid. Heavy (~30 Hz × every entity). Runtime-toggleable via - the DebugPanel "Diagnostics" section if `ACDREAM_DEVTOOLS=1`. -- `ACDREAM_PROBE_CELL=1` — one `[cell-transit]` line per - `PlayerMovementController.CellId` change: old → new cell, world - position, reason tag (`resolver` / `teleport`). Low volume — only - fires on actual cell crossings. Runtime-toggleable via the same - DebugPanel section. -- `ACDREAM_PROBE_PUSH_BACK=1` — emits three line types per physics - tick: `[push-back]` (per `BSPQuery.AdjustSphereToPlane` call), - `[push-back-disp]` (per `BSPQuery.FindCollisions` dispatch), - `[push-back-cell]` (per `Transition.CheckOtherCells` off-cell hit). - Heavy under motion (~100–500 lines/sec). Pair with retail's cdb - breakpoint set at `tools/cdb/a6-probe.cdb` for the A6.P1 capture - protocol. Runtime-toggleable via the DebugPanel. -- `ACDREAM_PROBE_FLAP=1` — capture probe for indoor visibility - decisions at frame boundaries. Used to converge the U.4c flap fix - (root indoor visibility at player's cell, not eye). -- `ACDREAM_PROBE_STICKY=1` — per-guid sticky-melee timeline: `[sticky]` - lifecycle lines (STICK/UNSTICK/LEASE-EXPIRE/TARGET-status teardown), - per-armed-tick steer lines (signed gap dist, applied delta, heading - delta), `[sticky-snap-skip]` at the suppressed NPC UP-snap site. - Heavy while a pack is stuck (~60 Hz × stuck count). Converged the - #171 residuals (the deep-overlap sign pin AP-82). -- `ACDREAM_PROBE_SUPPORT=1` — **what is holding a body up, and is the - collision geometry where the visual geometry is?** (#337, TEMPORARY). - `[support]`: one line per resolve **for every body, not just the player** - (a corpse falling through geometry is the cheapest control there is on - "movement code vs geometry data"). It samples the outdoor terrain - independently at the body's own out-XY and prints the contact plane's own - height at that same XY, so `support=terrain` / `object` / `none` is a - measurement rather than an inference; `cpSrc=` names the site that wrote - the plane so provenance cross-checks the classification. Edge-eager, - throttled to 4 Hz per body, and emits every 10 cm of vertical movement. - `[geom]`: once per GfxObj near the mover — the object's physics-BSP vertex - cloud against its visual mesh AABB in the same frame, with a verdict - (`coincident` REFUTES "collision isn't where the visual is"; - `no-physics-bsp` / `empty-physics-bsp` / `displaced` / `extent-mismatch` - each name a data defect). `ACDREAM_PROBE_RESOLVE` alone cannot separate - those cases — it carries no plane normal, no plane height, no terrain - sample and no provenance. -- `ACDREAM_WIRE_MESH=1` — upgrades the existing **F2** collision overlay from - a broadphase proxy cylinder to the real physics-BSP polygon edges (cyan) - beside the same objects' visual mesh boxes (magenta) and the terrain - surface (yellow). Settles "visual versus collision" by eye instead of by - log. `ACDREAM_WIRE_RADIUS=` sets the window (default 30). - TEMPORARY, with the #337 probe family. -- `ACDREAM_CAPTURE_RESOLVE=` — live capture of every player-side - `PhysicsEngine.ResolveWithTransition` call. Each call appends one - JSON Lines record with full inputs, PhysicsBody snapshot before AND - after, plus the `ResolveResult`. Filtered to `IsPlayer` mover flag - — NPC / remote DR calls don't pollute. Pairs with the trajectory - replay harness comparison tests to diff captured vs harness state - per field — the first divergence pinpoints missing apparatus state. - Capture is OFF when the env var is unset (one null-check cost per - call). -- `ACDREAM_DUMP_CELLS=` / `ACDREAM_DUMP_GFXOBJS=` — dump - resolved cell/GfxObj polygon tables as JSON when ids cache. Used - for harness fixture extraction. +Every environment variable and command-line argument the client reads — +what it does, its exact value shape, and **what else it changes about the +run** — is documented in +[`docs/launch-options.md`](docs/launch-options.md). That file is the single +source of truth and is enforced by `LaunchOptionsDocumentationTests`: a +flag without a documented row fails the build, and so does a documented row +whose read site was deleted. + +Two habits that list exists to enforce: + +- **Read the side-effects column before any measurement.** Flags that look + inert are not: `ACDREAM_AUTOMATION_ARTIFACT_DIR` also builds a per-frame + diagnostics referee (#432), and `ACDREAM_STREAM_RADIUS` measures a + streaming window production never uses. +- **A temporary probe dies with its investigation.** Add the row when you + add the probe; delete both in the commit that fixes the issue. ### Outbound motion wire format (acdream → ACE) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 80164fc7..d9866cfb 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,95 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #435 — Probe debt: 17 temporary probes outlived their closed investigations, 14 more name no owner + +**Status:** OPEN +**Severity:** LOW (no runtime defect; hot-path clutter and measurement noise) +**Filed:** 2026-08-24 (measured during the launch-options audit) +**Component:** diagnostics ownership + +**Measurement (2026-08-24, `docs/launch-options.md`):** the client reads 161 +`ACDREAM_*` variables. 64 are temporary probes. They cite 21 distinct +issues, of which **14 are already closed** — 17 probe rows are apparatus +whose investigation ended without the strip: + +| Closed issue | Probes that outlived it | +|---|---| +| #337 | `ACDREAM_PROBE_SUPPORT`, `ACDREAM_WIRE_MESH`, `ACDREAM_WIRE_RADIUS` | +| #119 | `ACDREAM_DUMP_ENTITY`, `ACDREAM_PROBE_VIEWER` | +| #32 | `ACDREAM_PROBE_REMOTE_LANDING` | +| #42 | `ACDREAM_AIRBORNE_DIAG` | +| #63 | `ACDREAM_PROBE_AUTOWALK` | +| #78 | `ACDREAM_PROBE_SHELL` | +| #83 | `ACDREAM_PROBE_WALK_MISS` | +| #105 | `ACDREAM_PROBE_TEXFLUSH` | +| #113 | `ACDREAM_PROBE_PHANTOM` | +| #131 | `ACDREAM_PROBE_OUTSTAGE` | +| #133 | `ACDREAM_PROBE_LIGHT` | +| #171 | `ACDREAM_PROBE_STICKY` | +| #334 | `ACDREAM_PROBE_REACH` | +| #338 | `ACDREAM_PROBE_STEP_HEIGHTS` | + +A further **14 temporary rows name no owning issue at all**, which is worse: +nothing records when they become safe to delete. + +**Why it matters:** each probe leaves a branch on its hot path even unset, +several re-read the environment per call rather than caching +(`ACDREAM_WB_DIAG` on every `Draw()`, `ACDREAM_DUMP_SURFACES` every render +frame until it fires, `ACDREAM_AIRBORNE_DIAG` per airborne resolve), and +the sheer count makes the real diagnostic surface hard to find. This is +also a correctness risk for headless: `HeadlessStaticStateAudit` +reflects over `PhysicsDiagnostics`' probe flags and refuses a multi-session +host when any is set, but it cannot see the probes that live outside that +owner class. + +**Fix shape:** per probe, confirm its issue is closed and no gate script +references it, then delete flag + read sites + doc row together. The +launch-options row is the checklist — `LaunchOptionsDocumentationTests` +fails if a row survives its read site, so the doc cannot drift during the +cleanup. Do NOT bulk-delete: a few (e.g. `ACDREAM_DUMP_ENTITY`) are +consumed by a second, unrelated probe (`ACDREAM_PROBE_OUTSTAGE` reuses its +id list), so deletion order matters. + +--- + +## #434 — The DebugPanel/DebugVM developer surface is unreachable, and ~40 doc comments still advertise it as live + +**Status:** OPEN +**Severity:** LOW (no runtime defect; a documentation-truth and dead-code problem) +**Filed:** 2026-08-24 (found during the launch-options audit) +**Component:** UI.Abstractions / diagnostics ownership + +**Symptom:** nothing in `src/` ever constructs `DebugPanel` or `DebugVM` +(`src/AcDream.UI.Abstractions/Panels/Debug/`). Their ImGui frontend was +deleted at Campaign V slice V11 — `SettingsDevToolsComposition.cs:13` +says so explicitly. Only `tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs` +instantiates them. Consequences: + +1. The ~35 `ACDREAM_*` reads inside `DebugPanel.cs`/`DebugVM.cs` are + unreachable in production (they only initialize the mirror state of a + panel that never exists). The flags themselves stay live — every one is + also read by its diagnostics owner — so no launch option is lost. +2. **Every "runtime-toggleable via the DebugPanel" claim in + `PhysicsDiagnostics`/`RenderingDiagnostics` XML docs is false.** Those + flags are startup-only today. `docs/launch-options.md` deliberately does + not repeat the claim; the owner-class comments still do. +3. `DebugVM` is still referenced as a TYPE by + `LiveCombatAttackOperations.Bind/Unbind` and + `DebugVmRenderFactsPublisher`, so those bind paths can never receive a + real instance. + +**Why not fixed with the audit:** deleting the pair is a real refactor +(test project churn plus two live type references), not a documentation +edit. Kept separate deliberately. + +**Fix shape:** either delete `DebugPanel`/`DebugVM` with their tests and +the two dead bind seams, or re-host them on the retained retail UI. Then +sweep the owner-class doc comments for "runtime-toggleable" and either +delete the claim or make it true. Decide which before touching either. + +--- + ## #433 — Stale entities from OTHER landblocks visible ("hanging in the air") after portal travel or /ls near Holtburg **Status:** OPEN diff --git a/docs/README.md b/docs/README.md index d8ab77b4..71558c70 100644 --- a/docs/README.md +++ b/docs/README.md @@ -86,6 +86,12 @@ document in the same change; do not leave both claims standing. pipeline, the self-hosted runners, and how alpha releases are published. Load-sensitive tests live in `Lane=Timing`; see [`release-gate.md`](release-gate.md) before adding to it. +- [`launch-options.md`](launch-options.md) is the SSOT for every environment + variable and command-line argument the client reads, including what each one + changes about the run beyond its obvious effect. Read the side-effects column + before trusting any measurement. Enforced by + `LaunchOptionsDocumentationTests`: a flag without a row fails the build, and + so does a row whose read site was deleted. - [`audit/`](audit/) contains completion and conformance audits. - [`reference/ace-commands.md`](reference/ace-commands.md) preserves the local ACE server's complete in-game command catalog and points to the authoritative diff --git a/docs/launch-options.md b/docs/launch-options.md new file mode 100644 index 00000000..de8c528b --- /dev/null +++ b/docs/launch-options.md @@ -0,0 +1,362 @@ +# acdream launch options — operator reference + +Every environment variable and command-line argument the acdream client +reads, what it does, and **what else it changes about the run**. + +**This is an operator's reference, not user documentation.** Players never +set these: the launcher owns installation and login, and the in-client +Options panel (F11) owns settings. If a flag here looks like something a +player would want, that is a signal it belongs in the Options panel, not a +signal to document it better. + +## How to use this document + +- **Running the client for yourself?** Read *Production launch* and stop. +- **Taking a measurement?** Read *Production launch*, then read the + *Side effects* column of every flag you are about to set. A flag that + changes what you are measuring is the normal case, not the exception. +- **Adding a flag?** Add its row in the same commit. `LaunchOptionsDocumentationTests` + fails the build otherwise — in both directions, so deleting a read site + without deleting its row fails too. + +### Why the side-effects column exists + +Two flags in this list were believed to be inert and were not: + +- `ACDREAM_AUTOMATION_ARTIFACT_DIR` reads like an output path. It also + constructs a per-frame diagnostics referee that re-enabled a retired + render pass, costing ~6 MB and ~14 ms **every frame** — three days of + performance measurements were silently taxed before anyone noticed + ([#432](ISSUES.md)). +- `ACDREAM_STREAM_RADIUS` reads like a radius knob. It forces the near + radius, only ever *raises* the far radius, and is then silently + discarded by any later quality apply — so a measurement taken with it + set is measuring a window production never uses. + +Assume a flag has a side effect until its row says otherwise. + +## Conventions + +- `=1` means the code tests for exactly the string `1`. Setting `true`, + `yes`, or `0` does **not** enable such a flag (and `0` does not disable + one whose test is "is the variable present"). +- **Default** is the behavior when the variable is unset. +- **Kind** is one of: + +| Kind | Meaning | +|---|---| +| `production` | Ordinary configuration; safe in a real run. | +| `measurement` | Profiling/instrumentation. Read the side effects before trusting numbers taken with it on. | +| `automation` | Drives scripted runs; usually implies extra machinery. | +| `permanent-probe` | A diagnostic toggle owned by a subsystem's diagnostics class. Expected to persist. | +| `temporary-probe` | Tied to an open investigation. Deleted with its issue — never build tooling on one. | +| `deprecated` | Superseded. Do not use for new work. | + +--- + +## Production launch + +The canonical connected launch against a local ACE server. PowerShell, +because the DAT path contains an apostrophe: + +```powershell +$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" +$env:ACDREAM_LIVE = "1" +$env:ACDREAM_TEST_HOST = "127.0.0.1" +$env:ACDREAM_TEST_PORT = "9000" +$env:ACDREAM_TEST_USER = "testaccount" +$env:ACDREAM_TEST_PASS = "testpassword" +$env:ACDREAM_RETAIL_UI = "1" +dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release +``` + +| Flag | Value | What it does | Side effects | Default | Read by | +|---|---|---|---|---|---| +| `ACDREAM_A2C` | `unset/""` keep preset; `"0"/"false"/"False"/"FALSE"` → off; any other non-empty → on | Overrides preset's `AlphaToCoverage` blend flag | Changes MSAA alpha-to-coverage blending mode for foliage/translucent draws — a visual-behavior change, not just perf | preset's `AlphaToCoverage` (High/Ultra=true, Low/Medium=false) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:52`) | +| `ACDREAM_AC_DIR` | `=` | Points at a real retail AC install dir; loads `/controls/controls.ini` to source retail keybind display strings for the retained UI. | Only has any effect when `ACDREAM_RETAIL_UI=1` (retained UI composed). Unset → `ControlsIni.Parse(string.Empty)`, an empty (not error) controls table — silent, no fallback file is searched. | unset (null) → empty controls table | `RuntimeOptions.AcDir` → `InteractionRetainedUiComposition.cs:610` | +| `ACDREAM_ANISOTROPIC` | `=` (`int.TryParse`, invariant) | Overrides preset's `AnisotropicLevel` texture filtering | Changes GPU texture sampling filter level (visual sharpness), not just perf | preset's `AnisotropicLevel` (Low=4, Medium=8, High/Ultra=16) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:49`) | +| `ACDREAM_CACHE_DIR` | `=` | Overrides the resolved cache-root directory (used for `DiagnosticsDirectory`, etc.) | none beyond redirecting cache I/O | Windows: `%LOCALAPPDATA%\acdream\cache`; Linux: `$XDG_CACHE_HOME/acdream` or `~/.cache/acdream` | `ApplicationPathSet.Resolve` (`ApplicationPathSet.cs:83`), via `IApplicationPathEnvironment` seam | +| `ACDREAM_CAMERA_ALIGN_SLOPE` | `=0` disables (anything else/unset = on) | selects whether the chase camera basis tilts to the player's 5-frame averaged velocity vs staying flat/horizontal on slopes | alters camera orientation / rendered view every frame; startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| true (on) | `AcDream.Core.Rendering.CameraDiagnostics.AlignToSlope` | +| `ACDREAM_CAMERA_COLLIDE` | `=0` disables (anything else/unset = on) | selects whether the chase camera sweeps a 0.3 m collision sphere from head-pivot to eye and stops at the first wall (retail spring-arm) | alters camera position every frame (camera can clip into geometry when disabled); startup-only | true (on) | `CameraDiagnostics.CollideCamera` | +| `ACDREAM_CONFIG_DIR` | `=` | Overrides the resolved config-root directory (`settings.json`, `keybinds.json`) | none beyond redirecting config I/O | Windows: `%APPDATA%\acdream`; Linux: `$XDG_CONFIG_HOME/acdream` or `~/.config/acdream` | `ApplicationPathSet.Resolve` (`ApplicationPathSet.cs:79`), via `IApplicationPathEnvironment` seam | +| `ACDREAM_DATA_DIR` | `=` | Overrides the resolved data-root directory (logs, screenshots, plugins) | none beyond redirecting data I/O | Windows: `%LOCALAPPDATA%\acdream`; Linux: `$XDG_DATA_HOME/acdream` or `~/.local/share/acdream` | `ApplicationPathSet.Resolve` (`ApplicationPathSet.cs:81`), via `IApplicationPathEnvironment` seam | +| `ACDREAM_DAT_DIR` | `=` | Fallback dat-directory when no positional argument is given. App: single read at `Program.cs:58`. Cli: read independently per-subcommand (each subcommand does `args.ElementAtOrDefault(N) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")`) plus once more for the default (no-subcommand) asset-inventory mode at line 152. | Two of the four `Program.cs` line numbers in the raw grep (91, 135) are **not reads** — they're the literal string `ACDREAM_DAT_DIR` inside `Log.Error` usage-text messages, not `GetEnvironmentVariable` calls. Only line 58 is a real read in `AcDream.App`. | none — hard usage error (exit 2) if unset and no positional arg | `Program.cs:58` (App); `Cli/Program.cs:24,35,47,59,71,84,113,125,137,152` (every Cli subcommand) | +| `ACDREAM_DISPLAY_PROTOCOL` | `="auto"` / `"x11"` / `"wayland"` (case-insensitive, trimmed); any other value throws `InvalidOperationException` at startup | Linux-only: forces the GLFW 3.4 platform-init hint (X11 vs Wayland vs auto) before any window is created; ignored entirely on Windows (always `Windows` protocol) | An invalid value is fatal at startup (throws before any window exists), not a silent fallback | unset → auto-detected from `XDG_SESSION_TYPE`/`WAYLAND_DISPLAY`/`DISPLAY`, falling back to GLFW `Automatic` | `GraphicalWindowBackendSelection.Resolve` (`GraphicalWindowBackendSelection.cs:26-58`) | +| `ACDREAM_FAR_RADIUS` | `=` | Overrides preset's `FarRadius` (outer streaming/reveal window, landblocks) | Enlarging changes streaming memory budget and what's resident/rendered — CLAUDE.md: leave unset for measurement/gate runs (same family as legacy `ACDREAM_STREAM_RADIUS`) | preset's `FarRadius` (Low=5, Medium=8, High=12, Ultra=15) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:47`) | +| `ACDREAM_LIVE` | `=1` (exactly the literal string `"1"`) | Core switch: connect to a live ACE server instead of running offline/no-connect. | The 4 non-`RuntimeOptions.cs` line numbers in the raw grep are **all comments or log-message text**, not reads — `SessionStartComposition.cs:39` is inside the string `"live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping"`; `Program.cs:126` is inside a `--session-config` override log line; `GameWindow.cs:614,627` are doc comments. The only actual parse is `RuntimeOptions.cs:141`. Requires `ACDREAM_TEST_USER`/`ACDREAM_TEST_PASS` too (`HasLiveCredentials`) or the session silently reports `MissingCredentials` and skips. Forced to effectively-on (LiveMode=true) unconditionally by `--session-config` launches regardless of this var. | `false` | `RuntimeOptions.LiveMode` → `SessionStartComposition.cs` (log text only), `Program.cs:126` (log text only), `GameWindow.cs:614,627` (comments only), consumed for real via `RuntimeOptions.HasLiveCredentials` and `WorldSession`/`GameRuntime` session-start gating | +| `ACDREAM_MAX_COMPLETIONS_PER_FRAME` | `=` | Overrides preset's per-frame streaming-completion throughput cap | Directly changes the streaming admission budget measured by perf/completion gates — do not vary during a measurement run | preset's value (Low=2, Medium=3, High=4, Ultra=6) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:59`) | +| `ACDREAM_MSAA_SAMPLES` | `=` (0/2/4/8) | Overrides preset's MSAA sample count | Changes GPU multisample anti-aliasing (visual + GPU-cost change) | preset's `MsaaSamples` (Low=0, Medium=2, High/Ultra=4) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:48`) | +| `ACDREAM_NEAR_RADIUS` | `=` | Overrides preset's `NearRadius` (inner streaming ring radius) | Changes the streaming/render window size — CLAUDE.md: leave unset for measurement/gate runs | preset's `NearRadius` (Low=2, Medium=3, High=4, Ultra=5) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:46`) | +| `ACDREAM_NO_AUDIO` | `=1` | Suppresses OpenAL device init entirely (headless / driver-broken machines). | Fail-open design: even without this flag, a missing/broken OpenAL driver already makes `IsAvailable=false` and all `Play*` calls no-ops — this flag is the explicit, deliberate version of the same fallback. When set, `LiveSessionWorldRuntime.WorldAudio` is `null` and logout-audio reset/resume steps no-op. | `false` | `RuntimeOptions.NoAudio` → `GameWindow.cs:1430` → `ContentEffectsAudioCompositionPhase` → `OpenAlAudioEngine.cs` (fail-open doc), `LiveSessionRuntimeFactory.cs:71` (`WorldAudio` gate) | +| `ACDREAM_PAK_PATH` | `=` | Overrides the path to the prepared machine-local asset bake (`acdream.pak`) that production world-mesh streaming reads through `IPreparedAssetSource`. | none beyond the obvious | unset → `/acdream.pak` | `RuntimeOptions.PreparedAssetPath` → `ContentEffectsAudioComposition.cs:53,372,379`, `GameWindow.cs:1420` | +| `ACDREAM_RESIDENCY_ALPHA_SCRATCH_MIB` | `=` (`>0`, else default; overflow-checked) | Byte ceiling for the retail alpha (translucency) draw queue's scratch buffer | Shrinking below production working set changes translucency-queue eviction/reflow behavior — not comparable to a default-budget perf run | 16 MiB | `ResidencyBudgetOptions.Parse` (`ResidencyBudgetOptions.cs:88-89`), flows through `RuntimeOptions.ResidencyBudgets`→`AlphaScratchBudgetProfile.Create`→`RetailAlphaQueue` ctor (`GameWindow.cs:721-725`) | +| `ACDREAM_RESIDENCY_ANIMATION_ENTRIES` | `=` (`>0`, else default) | Entry-count ceiling for the retained animation-data cache | Changes cache eviction cadence for animation data — a perf/memory measurement under a non-default value is not representative | 512 | `ResidencyBudgetOptions.Parse` (`:82-84`) | +| `ACDREAM_RESIDENCY_ANIMATION_MIB` | `=` (`>0`, else default) | Byte ceiling for the retained animation-data cache | Same cache-behavior-change caveat as the entries variant | 64 MiB | `ResidencyBudgetOptions.Parse` (`:79-81`) | +| `ACDREAM_RESIDENCY_AUDIO_MIB` | `=` (`>0`, else default) | Byte ceiling for the retained audio-buffer cache | Shrinking can force more frequent audio buffer re-decode/eviction | 32 MiB | `ResidencyBudgetOptions.Parse` (`:85-87`), consumed by `ContentEffectsAudioComposition.cs` | +| `ACDREAM_RESIDENCY_COMPOSITE_PHYSICAL_MIB` | `=` (`>0`, else default) | Byte ceiling for physically-resident composite (character palette/texture) GPU memory | Changes composite-texture eviction pressure — not representative of production if varied during a measurement run | 128 MiB | `ResidencyBudgetOptions.Parse` (`:67-69`), consumed by `TextureCache.cs` | +| `ACDREAM_RESIDENCY_COMPOSITE_UNOWNED_MIB` | `=` (`>0`, else default) | Byte ceiling for unowned/retained (not currently referenced) composite textures kept for reuse | Same eviction-pressure caveat | 64 MiB | `ResidencyBudgetOptions.Parse` (`:70-72`), consumed by `TextureCache.cs` | +| `ACDREAM_RESIDENCY_MESH_GPU_MIB` | `=` (`>0`, else default) | Byte ceiling for GPU-resident object mesh data | The single largest residency budget (1024 MiB default) — shrinking it directly forces more mesh re-upload/eviction; do not vary during an FPS/GPU-memory measurement run | 1024 MiB | `ResidencyBudgetOptions.Parse` (`:49-51`), consumed by `ObjectMeshManager.cs`/`WbDrawDispatcher.cs` | +| `ACDREAM_RESIDENCY_MESH_STAGING_ENTRIES` | `=` (`>0`, else default) | Entry-count ceiling for the mesh upload staging cache | Changes staging-buffer churn/eviction cadence | 256 | `ResidencyBudgetOptions.Parse` (`:64-66`) | +| `ACDREAM_RESIDENCY_MESH_STAGING_MIB` | `=` (`>0`, else default) | Byte ceiling for the mesh upload staging cache | Same staging-churn caveat | 128 MiB | `ResidencyBudgetOptions.Parse` (`:61-63`) | +| `ACDREAM_RESIDENCY_MESH_UNOWNED_ENTRIES` | `=` (`>0`, else default) | Entry-count ceiling for unowned (retained-for-reuse) object mesh entries | Changes mesh-cache eviction cadence | 50 | `ResidencyBudgetOptions.Parse` (`:52-54`) | +| `ACDREAM_RESIDENCY_PREPARED_MESH_ENTRIES` | `=` (`>0`, else default) | Entry-count ceiling for the CPU-side "prepared mesh" cache (post-classification, pre-upload) | Changes eviction cadence for prepared-mesh CPU memory | 100 | `ResidencyBudgetOptions.Parse` (`:58-60`) | +| `ACDREAM_RESIDENCY_PREPARED_MESH_MIB` | `=` (`>0`, else default) | Byte ceiling for the CPU-side prepared-mesh cache | Same eviction-cadence caveat | 128 MiB | `ResidencyBudgetOptions.Parse` (`:55-57`) | +| `ACDREAM_RESIDENCY_STANDALONE_UNOWNED_ENTRIES` | `=` (`>0`, else default) | Entry-count ceiling for unowned standalone (non-composite) texture entries | Changes standalone-texture eviction cadence | 256 | `ResidencyBudgetOptions.Parse` (`:76-78`), consumed by `TextureCache.cs` | +| `ACDREAM_RESIDENCY_STANDALONE_UNOWNED_MIB` | `=` (`>0`, else default) | Byte ceiling for unowned standalone texture memory | Same eviction-cadence caveat | 32 MiB | `ResidencyBudgetOptions.Parse` (`:73-75`) | +| `ACDREAM_RETAIL_CHASE` | `=0` disables (anything else/unset = on) | selects the retail-faithful `RetailChaseCamera` vs. the legacy rigid-follow `ChaseCamera` | swaps the entire active camera implementation — changes camera motion/feel; startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| true (retail camera on) | `CameraDiagnostics.UseRetailChaseCamera` | +| `ACDREAM_RETAIL_CLOSE_DEGRADES` | inverted: `="0"` disables; any other value (incl. unset) enables | Default-**on** real gameplay behavior: applies retail's close-range LOD mesh-part swap (`GfxObjDegradeResolver`) to humanoid setups (issue #47), matching retail's close-detail degrade. | Inverted default (opposite of every other boolean flag in this table — presence of the literal string `"0"` is what disables it, not presence of `"1"` enabling it). Documented explicitly as "set only for before/after diagnostic comparisons" — so although default-on production behavior, its *disable* path exists purely for A/B measurement. | `true` (enabled) unless value is exactly `"0"` | `RuntimeOptions.RetailCloseDegrades` → `DatLiveEntityProjectionMaterializer.cs:275-276,480-498` | +| `ACDREAM_RETAIL_UI` | `=1` | Switches on the retained retail UI host tree (`UiHost`/`UiRoot`, D.2b). Without it, no retained UI is composed at all — e.g. no chargen Appearance page, no Summary page. | **Forced to `true` unconditionally** for every `--session-config` / launcher launch (`RuntimeOptions.cs:282`, "a session-config launch IS a product launch — the retail UI is the shipped UI, not a dev option"), regardless of this env var's value — the env var only matters for the bare env-var dev-flow launch path. | `false` for the env-var dev flow; `true` always for `--session-config` launches | `RuntimeOptions.RetailUi` → `LivePresentationComposition.cs:1108-1131` (gates retained-UI mount via `InteractionRetainedUiComposition`), `GameWindow.cs:455,566` (comments), `RuntimeOptions.cs:277` | +| `ACDREAM_TEST_HOST` | `=` | ACE server hostname for live-mode connect. | none | `"127.0.0.1"` | `RuntimeOptions.LiveHost` (`RuntimeOptions.cs:142`) | +| `ACDREAM_TEST_PASS` | `=` | ACE account password for live-mode connect. | Redacted in `RuntimeOptions.ToString()`/diagnostic printing by design (`PrintMembers` override, `RuntimeOptions.cs:326-342`) — defense-in-depth so it can never leak into a log/exception via the record's default printing. | `null` (empty → `HasLiveCredentials` false) | `RuntimeOptions.LivePass` (`RuntimeOptions.cs:145`) | +| `ACDREAM_TEST_PORT` | `=` | ACE server port for live-mode connect. | none | `9000` | `RuntimeOptions.LivePort` (`RuntimeOptions.cs:143`) | +| `ACDREAM_TEST_USER` | `=` | ACE account name for live-mode connect. | none | `null` (empty → `HasLiveCredentials` false) | `RuntimeOptions.LiveUser` (`RuntimeOptions.cs:144`) | +| `ACDREAM_VULKAN_DEVICE` | `=` (decimal index) or `=` (case-insensitive device-name match) | Overrides automatic Vulkan physical-device selection (normally: discrete > integrated > virtual > CPU, tie-broken by device-local heap size) — for multi-GPU machines. | A bare-digits value is matched as an index ONLY (never falls through to substring match) specifically because digits like `"7"` are substrings of real device names ("AMD Radeon RX 9070 XT") — a fallback would silently select the wrong device by coincidence. An override matching nothing falls back to the automatic choice (does not fail startup) and records why in the capability report. | `null` → automatic ranked choice | `RuntimeOptions.VulkanDeviceOverride` → `VulkanPhysicalDeviceSelection.Choose` (`VulkanPhysicalDeviceSelection.cs:54-100`), consumed at `VulkanGraphicsContext.cs:207,322` | + +## Command-line arguments + +### `AcDream.App` + +| Arg | What it does | Side effects | +|---|---|---| +| `` (positional) | Dat directory; outranks `ACDREAM_DAT_DIR`. | Not read at all once `--session-config` is present. | +| `--session-config ` | The launcher's launch path: endpoint, account, credential reference, character selector, status file, plugins, login commands. | **Overrides `ACDREAM_LIVE` and every `ACDREAM_TEST_*`** (logged at startup). Diagnostic flags stay env-controlled. Missing value is a startup error. | + +### `AcDream.Headless` + +Its usage banner matches the parser exactly. `validate` loads and checks a +config without connecting; `run` connects. + +| Arg | What it does | Side effects | +|---|---|---| +| `validate` \| `run` (positional) | Selects the mode; must be the first argument. | Anything else is a parse error. | +| `--config ` | The versioned headless session-configuration document. Required. | — | +| `--config-dir` / `--data-dir` / `--cache-dir` `` | Override each portable path root. | Merged over the config document's own `process.paths`; the command line wins. | +| `-user` / `--user`, `-password` / `--password` | Direct single-session credentials, bypassing the config's credential source. | Plaintext in the process command line — prefer the config's credential reference. | +| `--help` / `-h` (or no args) | Prints usage, exits 0. | — | + +### `AcDream.Launcher` + +| Arg | What it does | Side effects | +|---|---|---| +| `--verify-publish` | Packaging smoke probe: parses arguments and exits 0 without opening a display or resolving user paths. | — | +| `--config-dir` / `--data-dir` / `--cache-dir` `` | Override each path root. | **All three or none** — supplying a subset is an error. Must be absolute. | +| `--update-manifest-uri ` | Points the self-updater at a different release manifest (test-feed seam). | Must be `https://` (or loopback `http://`). Changes where updates come from — do not point a real install at a test feed. | +| `--acdream-self-update-helper-v1`, `--acdream-self-update-confirm-v1` | Internal re-exec markers for the self-update handoff. | Not user-facing; never pass these by hand. | + +### `AcDream.Cli` + +A dat-dump and measurement tool dispatched by a positional subcommand +(`args[0]`); no `--flag` options. Most subcommands take a dat directory and +fall back to `ACDREAM_DAT_DIR`. + +- **Measurement:** `summarize-frame-history `, + `compare-screenshots [channelTolerance=2] [maxDifferentFraction=0.001] [mask.png]`, + `probe `. +- **Dat inspection:** no subcommand (asset-type inventory), `dump-vitals-bars`, + `dump-vitals-layout [0xLayoutId]`, `list-ui-layouts [0xRootType]`, + `dump-sprite-sheet <0xId,...>`, `dump-font-atlas [0xFontId] [sample] [outBase]`, + `dump-edges <0xId>`, `export-ui-sprite <0xId> [out.png]`. +- **Mockup rendering:** `render-vitals-mockup [out.png]`, `mock-selbar [out.png]`, + `crop `. + +## Measurement and profiling + +| Flag | Value | What it does | Side effects | Default | Read by | +|---|---|---|---|---|---| +| `ACDREAM_CAPTURE_RESOLVE` | `=` | appends one JSON-Lines record (full before/after `PhysicsBody` snapshot) per player-side `ResolveWithTransition` call, filtered to `IsPlayer` movers | real per-tick allocation (snapshot object graph + `System.Text.Json` serialize) and buffered file I/O (`AutoFlush=false`) for the local player only; will skew any perf measurement of local-player physics while active; feeds `CellarUpTrajectoryReplayTests` fixtures | unset (off) | `AcDream.Core.Physics.PhysicsResolveCapture` (`CapturePath`) | +| `ACDREAM_COLLISION_SHADOW_DIR` | `=` | output directory for Slice I5 graph/flat collision-shadow mismatch artifacts | only takes effect when `ACDREAM_COLLISION_SHADOW_EVERY>0`; directory creation + file writes on mismatch | `/.test-out/collision-shadow` | `PhysicsDiagnostics.CollisionShadowArtifactDirectory` | +| `ACDREAM_COLLISION_SHADOW_EVERY` | `=` | when >0 and the cache is constructed with `requirePreparedCollision:false`, arms a `CollisionShadowVerifier` that re-runs the graph-vs-flat collision referee every Nth traversal entry (`PhysicsDataCache` ctor) | extra CPU on sampled ticks + mismatch-artifact file I/O; graph path stays authoritative regardless of mismatch (doc-asserted, not independently verified here) — does not change production physics results, but does add work when active | `0` (disabled) | `PhysicsDiagnostics.CollisionShadowSampleEvery` (parsed via `ParsePositiveInt`, non-positive → 0) | +| `ACDREAM_DAY_GROUP` | `=` | Forces Dereth's day-group (weather preset) selection instead of the retail hash-based pick, "useful for visually A/B-testing each weather preset against retail" (own doc comment). | **Dead second read**: the `SkyDescLoader.cs:252` raw read only feeds `SelectDayGroupIndex`, which is only called from `ActiveDayGroup(double)` and the `DefaultDayGroup` property — and grepping all of `src/` finds **zero production call sites** for either. That whole path is unreachable; only the typed `RuntimeOptions.ForcedDayGroupIndex` → Runtime path is live. Bounds differ too: the typed path only checks `>= 0` (`TryParseNonNegativeInt`) and Runtime clamps out-of-range to `null`; the dead Core-layer path checks `forced >= 0 && forced < DayGroups.Count` directly. `SkyState.cs:400,403` are doc-comment mentions only, not reads. | unset → normal server/date-driven hash selection | `RuntimeOptions.ForcedDayGroupIndex` (typed) → `GameWindow.cs:718` → `WorldEnvironmentController` → `RuntimeWorldEnvironmentState` (Runtime, live path); **also** raw `Environment.GetEnvironmentVariable` at `SkyDescLoader.cs:252` (Core layer, separate parse) | +| `ACDREAM_DISABLE_TIER1_CACHE` | `="1"` (ordinal exact match; anything else = enabled) | A/B diagnostic that forces **every** static (non-animated) entity through the slow per-entity classification path, bypassing the Tier-1 classification cache (`#53`) | Materially changes per-frame CPU cost for entity classification — a perf/FPS measurement taken with this set is NOT representative of production and must not be compared against a normal run | unset (cache enabled) | `WbDrawDispatcher` ctor field `_tier1CacheDisabled` (`WbDrawDispatcher.cs:473-474`) | +| `ACDREAM_FRAME_HISTORY` | `=` | opts into a per-frame CSV history capture (frame idx, timestamps, per-stage CPU us, GPU us, alloc bytes) alongside the aggregated 5 s `[frame-prof]` report | allocates a `List` with ~131,072-record (~9 MiB) initial capacity, growing further for longer captures (~72 B/record, ~43 MB/hour at 165 fps) held in memory for the whole run; CSV write happens ONLY at `Dispose`/shutdown (no frame-thread I/O); only takes effect while `ACDREAM_FRAME_PROF` is ALSO on | unset (off) | `RenderingDiagnostics.FrameHistoryPath` / `AcDream.App.Diagnostics.FrameProfiler` | +| `ACDREAM_ORBIT_DISTANCE_METERS` | `=`, must be finite and `>0` | Diagnostic-only initial distance for the offline orbit camera, so deterministic renderer acceptance captures land inside a finite shadow reach. | Own doc comment: "used by deterministic renderer acceptance captures." Rejects non-finite/non-positive values silently (parses to `null`, camera default used). | `null` (unset) → normal camera default | `RuntimeOptions.InitialOrbitDistanceMeters` → `GameWindow.cs:1412` → offline orbit-camera composition | +| `ACDREAM_ORBIT_PITCH_DEGREES` | `=`, clamped `[-89, 89]` | Diagnostic-only initial orbit camera elevation. | Values outside `[-89,89]` or non-finite are silently rejected (→ `null`, default kept) rather than clamped. | `null` | `RuntimeOptions.InitialOrbitPitchDegrees` → `GameWindow.cs:1414` | +| `ACDREAM_ORBIT_YAW_DEGREES` | `=`, must be finite | Diagnostic-only initial orbit camera heading. | Non-finite values silently rejected (→ `null`). | `null` | `RuntimeOptions.InitialOrbitYawDegrees` → `GameWindow.cs:1413` | +| `ACDREAM_PROBE_REVEAL_RADIUS` | `==1` (unparsable or `<1` → override absent; floor is 1, not 0) | #280 A/B measurement probe: forces the OUTDOOR reveal gate to use this landblock radius instead of the derived streaming window (near radius clamped to it), so a route can be measured with the pre-#280 behavior (`=1`, old `OutdoorNeighborhoodRadius`) vs. current | **Changes what gets revealed, not just measured** — genuinely resizes the reveal/visible window used by the live reveal gate. CLAUDE.md: "Leave it unset for any measurement or gate run — with it set you are measuring a different window than production." `=0` is rejected by the parser specifically because it would hang the very A/B route it exists to measure (`RequiredRenderRadius==0` fails `invalid-readiness-shape`). Not a user setting, not in Settings/RuntimeOptions, not persisted. | unset (derivation in charge, no override) | `StreamingDiagnostics.RevealRadiusOverride` (`StreamingDiagnostics.cs:25-27,76-80`), applied by `StreamingDiagnostics.ApplyRevealRadiusOverride` | +| `ACDREAM_PROBE_WORLD_FRAME` | `=1` | gates one `[world-frame] agree` line per projected conversion in `DatLiveEntityProjectionMaterializer`, recording the world-frame center both `LiveWorldOriginState` (App) and Runtime's physics-state owner used (issue #283, "measurement only; it never gates placement") | print-only | off | `PhysicsDiagnostics.ProbeWorldFrameEnabled` | +| `ACDREAM_SKY_PHASE_SECONDS` | `=` (any finite value; negative accepted, taken mod 1 per axis) | Campaign V slice V7 instrument-determinism pin: freezes the sky's cloud-sheet UV scroll to a fixed elapsed-seconds value instead of wall-clock time, so two launches of a differential/offline gate agree about cloud position. | **Non-obvious dual effect**: this ONE var pins TWO independently-designed clocks that happen to share a name-adjacent purpose — the sky renderer's cloud scroll (`SkyRenderer.AnimationPhaseSecondsOverride`) AND, since Campaign VM slice VM6, the atmospheric post-process graph's foliage-wind clock (`_windClockSecondsOverride`). A gate that only knows about "sky clouds" and sets this to freeze them will *also* freeze foliage-wind evolution — deliberately snapped-to-target on the first advance per an A6 review fix, but still a second surface a naive reader wouldn't expect this var to touch. Distinct from `ACDREAM_DAY_GROUP`/`ACDREAM_WORLD_TIME`, which pin the OTHER sky clock (day group/sun angle) — retail's clouds drift independently of the calendar date by design. | `null` → wall-clock driven (every ordinary run) | `RuntimeOptions.SkyAnimationPhaseSeconds` → `SkyRenderer.cs:79,85` (cloud UV scroll) **and** `AtmosphericPostProcessGraph.cs:560,586,671` (foliage-wind clock) | +| `ACDREAM_STREAM_WORK_COMPLETIONS` | `=` (`>0`, else default) | Per-frame ceiling on streaming completion admissions on the update thread | Class doc comment states explicitly: this whole `ACDREAM_STREAM_WORK_*` family "exists for A/B measurement only" — not a user/production setting. Directly changes streaming throughput per frame; do not compare a measurement taken with this set against a default run. | 64 | `StreamingWorkBudgetOptions.Parse` (`StreamingWorkBudgetOptions.cs:56-58`) | +| `ACDREAM_STREAM_WORK_CPU_MIB` | `=` (`>0`, else default) | Per-frame ceiling on adopted (newly resident) CPU bytes on the update thread | A/B-measurement-only family; changes per-frame CPU admission budget | 8 MiB | `StreamingWorkBudgetOptions.Parse` (`:59-61`) | +| `ACDREAM_STREAM_WORK_DEST_RESERVE_PERCENT` | `=`, exclusive `0 < x < 100`, else default; stored as fraction (`percent/100`) | Fraction of the per-frame work budget reserved for the active reveal destination lane vs. background streaming | A/B-measurement-only family; reallocates frame budget between destination-lane and background streaming work, changing reveal-latency characteristics | 0.75 (75%) | `StreamingWorkBudgetOptions.Parse`/`ParseReservePercent` (`:71-73,154-169`) | +| `ACDREAM_STREAM_WORK_ENTITY_OPS` | `=` (`>0`, else default) | Per-frame ceiling on entity-cursor operations (small ops, e.g. one dictionary/index write each) on the update thread | A/B-measurement-only family. Doc comment: elapsed-time ceiling (`ACDREAM_STREAM_WORK_MS`) remains the authoritative CPU guard — this is a secondary cap, deliberately loose (leaves >90% of the time budget unused at default) | 4,096 | `StreamingWorkBudgetOptions.Parse` (`:62-64`) | +| `ACDREAM_STREAM_WORK_GL_RETIRE_OPS` | `=` (`>0`, else default) | Per-frame ceiling on GL/GPU resource-retirement operations on the update thread | A/B-measurement-only family; changes retirement cadence, which changes when GPU memory is actually reclaimed | 64 | `StreamingWorkBudgetOptions.Parse` (`:68-70`) | +| `ACDREAM_STREAM_WORK_GPU_MIB` | `=` (`>0`, else default) | Per-frame ceiling on GPU upload bytes on the update thread | A/B-measurement-only family; directly changes per-frame upload throughput | 8 MiB | `StreamingWorkBudgetOptions.Parse` (`:65-67`) | +| `ACDREAM_STREAM_WORK_HOLD_DEST_MS` | `=` (`>0` and finite, else default `8.0`) | Absolute (not quality-scaled) time ceiling for destination-lane work during a portal/login hold; never shrinks a profile whose own ceiling is already ≥ this value | Explicitly documented as "NOT a user setting... exists for A/B measurement only, matching the rest of the `ACDREAM_STREAM_WORK_*` family" — do not set outside a deliberate hold-latency A/B comparison | 8.0 ms | `StreamingWorkBudgetOptions.Parse` (`:74-76`); `HoldDestinationCeilingMilliseconds` widens the frame meter via `StreamingWorkBudget.WidenForDestinationHold` while a destination reservation hides the world behind the authored tunnel (#418) | +| `ACDREAM_STREAM_WORK_MS` | `=` (`>0` and finite, else default) | Per-frame elapsed-time ceiling for update-thread streaming work — "the authoritative CPU guard" per the entity-ops comment | A/B-measurement-only family; this is the primary per-frame time budget for streaming — changing it changes both perceived streaming latency and measured frame cost | 2.0 ms | `StreamingWorkBudgetOptions.Parse` (`:53-55`) | +| `ACDREAM_UNCAPPED_RENDER` | `=1` | Removes the normal VSync/refresh-rate software pacer, so the render loop runs as fast as the GPU/CPU allow. | Own doc comment (`RuntimeOptions.cs:147-150`): "Normal presentation is always bounded by VSync or a refresh-rate software pacer. This explicit diagnostic is the sole way to measure truly uncapped renderer throughput." Not representative of what a real player experiences — exists purely for throughput measurement. | `false` → VSync/pacer-bounded | `RuntimeOptions.UncappedRendering` → `GameWindow.cs:765` → `DisplayFramePacingController`; also `VulkanBringUpHost.cs:75` | +| `ACDREAM_WB_DIAG` | `=1` (raw `string.Equals` ordinal compare) | (a) `GameWindow`: gates the `[FRAME-DIAG]` render-thread entity-upload-distribution report; (b) `WbDrawDispatcher`: gates `BeginRhiTimer`/`SampleRhiTimers`, wrapping the opaque/detail/transparent draw passes in extra Vulkan GPU timer-scope queries and periodically logging a `[WB-DIAG]` CPU/GPU median/p95 report | adds extra per-pass GPU timestamp queries every frame while on — genuine measurement overhead; NOT read through `RenderingDiagnostics` or any diagnostics-owner class, unlike every other flag in this set — flag for whitelisting (see Notes #2); the flag's supposed interaction with `ACDREAM_FRAME_PROF`'s GPU query is stale documentation (see Notes #1) | unset (off) | read directly at `WbDrawDispatcher.cs:2061-2064` (every `Draw()`/`BeginEntityDispatch` call, i.e. effectively per frame, NOT cached) and cached once as a readonly field at `GameWindow.cs:153-156` | +| `ACDREAM_WORLD_TIME` | `=`, accepted only in `[0, 1)` | Campaign V slice V7 instrument-determinism pin: freezes the Dereth day fraction (and therefore sun direction, sky keyframe, and every lit surface) instead of following the server clock. | Outranks BOTH the server `TimeSync` clock and the `/time` slash command's `SetDebugTime` (which is deliberately transient — the next `TimeSync` clears it); this pin does not clear. Distinct axis from `ACDREAM_DAY_GROUP` (day-group/weather-preset selection) and `ACDREAM_SKY_PHASE_SECONDS` (cloud scroll + foliage wind) — the calendar DATE still advances, only the intra-day fraction freezes. Anything outside `[0,1)` (including negative, unparseable, or unset) leaves the server clock alone entirely — no partial/clamped behavior. | `null` → server clock | `RuntimeOptions.PinnedWorldDayFraction` → `GameWindow.cs:720` → `WorldEnvironmentController` → `Runtime.WorldTime.PinnedDayFraction` | + +## Automation + +A scripted route run adds three things at once — a session config so the +client self-selects a character, a route script, and an artifact directory: + +```powershell +$env:ACDREAM_UI_PROBE_SCRIPT = "$scratch\route.txt" +$env:ACDREAM_AUTOMATION_ARTIFACT_DIR = "$scratch\artifacts" +$env:ACDREAM_FRAME_PROF = "1" +$env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv" +& $exe --session-config "$scratch\session.json" +``` + +**Two traps this recipe exists to document:** + +1. **Without `--session-config`, the client stops at character select** and + the route never runs. The session JSON supplies the endpoint, account, + and a character `index` for auto-selection. +2. **`ACDREAM_AUTOMATION_ARTIFACT_DIR` is not free.** It constructs the + render-scene oracle, which fingerprints every resident entity every + frame. The allocation cost was fixed in + [#432](ISSUES.md), but the CPU walk remains — automation-run frame + rates are diagnostics-loaded and must only be compared against other + automation runs, never against a plain run. Some route verbs + (`wait world-*`) additionally do nothing unless this is set. + +| Flag | Value | What it does | Side effects | Default | Read by | +|---|---|---|---|---|---| +| `ACDREAM_AUTOMATION_ARTIFACT_DIR` | `=` | Output directory for the retail-UI automation probe's checkpoint JSON + screenshot PNG artifacts; gates whether the full `WorldLifecycleAutomationController` (checkpoint/screenshot/render-pack-automation capable) is composed at all vs. the cheaper facts-only `WorldRevealFactsAutomationRuntime` fallback (`wait world-ready/visible` verbs work either way per issue #415's fix; checkpoint/screenshot verbs report "requires ACDREAM_AUTOMATION_ARTIFACT_DIR" without it). | **Known #432 surprise, confirmed still live**: `FrameRootComposition.cs:349-353` — `AutomationArtifactDirectory is not null` (together with `RetainedUi?.Screenshots is not null`) unconditionally constructs a `CurrentRenderSceneOracle` **and** a `RenderSceneShadowComparisonController` — a per-frame diagnostics referee — regardless of whether any checkpoint/screenshot is ever actually requested that session. Merely setting this var for its "just an output path" purpose pays the per-frame comparison cost for the whole run. | unset (null) → facts-only automation runtime, no per-frame referee constructed | `RuntimeOptions.AutomationArtifactDirectory` → `FrameRootComposition.cs:351,543-627`, `WorldLifecycleAutomationController.cs`, `RetailUiAutomationScriptRunner.cs:108` | +| `ACDREAM_AUTOMATION_EXACT_FRAMEBUFFER` | `=1` | Forces the graphical host to use the persisted display resolution as the *initial* size of a **borderless** window at creation, so the OS window manager cannot clamp a decorated window to the desktop work area — needed for pixel-exact automated screenshot comparison. | Changes window chrome (borderless) at startup — a visible difference from an ordinary launch, not just an internal measurement knob. | `false` → normal decorated window | `RuntimeOptions.ExactAutomationFramebuffer` → `GameWindow.cs:852` (`CreateStartupWindowOptions`) | +| `ACDREAM_BAKE_PUBLISH_NONCE_V1` | `=<32-hex GUID "N" format>` | Launcher-to-bake-child authorization token: when present and valid, the bake child takes a cross-process publish file lock + writes an authorization file before atomic publication (serializes with launcher recovery) | If present but fails `IsValidNonce` (not a 32-char Guid "N"), throws `InvalidOperationException` and aborts the bake. When absent, bake runs unguarded (standalone mode). Never set this manually outside the launcher's own child-process spawn. | unset (standalone unguarded bake) | `BakePublicationGuardPaths.cs:12`, read by `BakePublicationGuard.AcquireIfRequested` (`AcDream.Bake/BakePublicationGuard.cs:18`); set by `BakeProcessRunner.cs:150/162` | +| `ACDREAM_NET_DROP_DIR` | `="out"`/`"in"`/anything-else (incl. unset) → `Both` (case-insensitive) | Selects which direction(s) — outbound, inbound, or both — the deterministic loss-injection decorator drops | Only takes effect when `ACDREAM_NET_DROP_PCT>0` (decorator is structurally absent otherwise). Drives real datagram loss on the live connection — the injection point for `tools/run-connected-loss-gate.ps1`. Never set during a normal/measurement run. | `Both` | `NetDiagnostics.NetDropDir` (`NetDiagnostics.cs:88-90,98-104`), consumed by `LossyTransportDecorator.WrapIfConfigured` (`Transport/LossyTransportDecorator.cs:21-22`), also read at `WorldSession.cs:901-907` (comment only) | +| `ACDREAM_NET_DROP_PCT` | `=` (out-of-range or unparsable → 0) | Percent chance (post-handshake-arming, per droppable datagram) that the deterministic `LossyTransportDecorator` drops a packet in the configured direction(s) | **Fault injection.** `>0` wraps the real socket transport in a packet-dropping decorator for the whole session — genuinely breaks/delays delivery to exercise N1-N4 reliable-transport recovery. At 0 the decorator is never constructed (zero structural cost). Must be 0/unset for any normal run or non-loss-gate measurement. | `0` (off, decorator absent) | `NetDiagnostics.NetDropPercent` (`NetDiagnostics.cs:60-69,92-96`), consumed by `LossyTransportDecorator.WrapIfConfigured` (`:21`), wired at `WorldSession.cs:901-907` | +| `ACDREAM_NET_DROP_SEED` | `=` (unparsable → `1`) | PRNG seed for the loss decorator (outbound seeded with `seed`, inbound with `~seed`) — same seed reproduces an identical drop pattern | Only matters when `ACDREAM_NET_DROP_PCT>0`; makes fault injection deterministic/reproducible for the connected loss gate | `1` | `NetDiagnostics.NetDropSeed` (`NetDiagnostics.cs:75-82`), consumed by `LossyTransportDecorator` (`:21-22`) | +| `ACDREAM_OPEN_CHARGEN` | `=1` | Campaign CC slice CC4 interim env/test-only seam: opens the character-creation screen (`gmCharGenMainUI`) automatically once Runtime's chargen view goes active, bypassing the real retail Create-Character-button transition. Fires once per mount (`_openOnStartConsumed` latch). | Own doc comment explicitly calls this "interim env/test-only" — Campaign CC (closed 2026-08-16, user-accepted) later wired the real Create button with its roster<55-slot ghost gate, so this flag is now a bypass of that gate for automation/testing rather than the only way in. | `false` | `RuntimeOptions.OpenCharacterCreationOnStart` → `CharacterCreationUiController.cs:21,524-530` | +| `ACDREAM_UI_PROBE_DUMP` | `=1` | Enables the retail-UI automation probe's diagnostic dump path and feeds `RetailUiProbeBindings`/`RetailUiAutomationScriptRunner`. Also part of `RuntimeOptions.UiProbeEnabled` (`UiProbeDump \ | \ | UiProbeScript is set`). | `RuntimeOptions.UiProbeDump` → `LivePresentationComposition.cs:1465-1495`, `InteractionRetainedUiComposition.cs:1092-1100` | +| `ACDREAM_UI_PROBE_SCRIPT` | `=` | Path to a script file the `RetailUiAutomationScriptRunner` executes against the retained UI (pointer/semantic-input command playback) for scripted UI regression testing. | Also flips `RuntimeOptions.UiProbeEnabled` true even without `ACDREAM_UI_PROBE_DUMP=1`. | `null` | `RuntimeOptions.UiProbeScript` → `InteractionRetainedUiComposition.cs:1094` | +| `ACDREAM_VULKAN_FORCE_UNSUPPORTED` | `=` (case-insensitive property name, e.g. `MultiDrawIndirect`) | Test knob (Slice V5): clears one named required Vulkan feature from the capability record to synthetically fail the gate, so the `NotSupportedException` → exit-code-4 → report path can be exercised on hardware that actually supports everything. | Deliberately breaks Vulkan startup when set to a matched feature name — this is a "make it fail on purpose" gate-testing flag, never appropriate for a normal or measurement run. | `null` → real capabilities used unmodified | `RuntimeOptions.VulkanForcedUnsupportedFeature` → `VulkanCapabilityRecord.Without` (`VulkanCapabilityRecord.cs:113-119`), consumed at `VulkanGraphicsContext.cs:339` | +| `ACDREAM_VULKAN_PROBE` | `=1` | Runs the standalone Vulkan capability-probe/bring-up harness (opens its own window, runs the capability gate, presents synthetic V6c/V6d verification scenes, captures one screenshot) **instead of** the real client composition host, then exits. | Its class doc (`VulkanBringUpHost.cs:11`) is stale — claims it additionally requires `ACDREAM_RENDER_BACKEND=vulkan`, which is no longer read anywhere (see that flag's row); in the current code this flag ALONE gates entry (`GameWindow.cs:828: if (_options.VulkanCapabilityProbe)`). | `false` → normal composition host | `RuntimeOptions.VulkanCapabilityProbe` → `GameWindow.cs:828` → `VulkanBringUpHost` | +| `ACDREAM_VULKAN_PROBE_FRAMES` | `=` (non-negative) | Bounds the bring-up probe harness to N presented frames so it can run unattended in CI, instead of presenting until a human closes the window. | The frame budget never cuts a pending screenshot capture short — the loop stays open until the screenshot has been attempted even past the budget, so an unattended run's whole product (a PNG) is guaranteed. Zero (unset/unparseable/explicit `0`) keeps the interactive wait-for-close behavior. | `0` → interactive (wait for window close) | `RuntimeOptions.VulkanCapabilityProbeFrames` → `VulkanBringUpHost.cs:141-249` | + +## Permanent diagnostics + +| Flag | Value | What it does | Side effects | Default | Read by | +|---|---|---|---|---|---| +| `ACDREAM_CAPTURE_PLAYER_QUANTA` | `=` (any non-whitespace path) | Opt-in JSON-Lines trace of every admitted player physics quantum (position/orientation/velocity/contact-plane snapshots at each stage boundary of `CPhysicsObj::UpdateObjectInternal`) | Appends+flushes one JSON line per physics quantum to the file (real file I/O on the physics tick when enabled); disabled path costs one static string null/empty check, no allocation. Read once into a mutable static property (settable via `ResetForTest`) rather than a typed options object. | unset (disabled, zero-alloc) | `PlayerPhysicsQuantumCapture` static class (`AcDream.Runtime/Gameplay/PlayerPhysicsQuantumCapture.cs:22`) | +| `ACDREAM_DUMP_MOTION` | `=1` | prints `UM`/`[UM_STALE]`/`[MOTIONDONE]`/`VU.land`/raw-hex wire dump lines tracing inbound `UpdateMotion` handling, remote ground-contact edges, and motion-done callbacks (bug-a/#32 stuck-cast subthread is temporary; core trace is long-lived) | print-only, but the raw-site reads in `LiveEntityNetworkUpdateController.cs` and `UpdateMotion.cs` fire on EVERY inbound motion/UM event (not cached) — `Environment.GetEnvironmentVariable` call per packet even when off; `UpdateMotion.cs`'s branch additionally builds a `StringBuilder` hex dump when on. Rule-5 violation (raw reads outside a diagnostics-owner class) at 5+ call sites | off | THREE independent readers: `PhysicsDiagnostics.DumpMotionEnabled` (owner, appears unconsumed — see Notes), `AnimationPresentationDiagnostics.FromEnvironment()` (App owner record, cached once at startup, consumed by `LiveEntityAnimationPresenter`), and raw `Environment.GetEnvironmentVariable` reads scattered across `LiveEntityNetworkUpdateController.cs` (4 sites) + `Core.Net/Messages/UpdateMotion.cs:163` + `Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:630` | +| `ACDREAM_DUMP_PLAYSCRIPT` | `="1"` (ordinal) | Traces PhysicsScript playback: missing/empty script resolution, malformed `StartTime` entries, and other `[pes]`-prefixed hook-dispatch events | print-only (`Console.WriteLine`) at all 4 use sites (`:85-86,136,300,328`) | unset (off) | `PhysicsScriptRunner.DiagEnabled` (`PhysicsScriptRunner.cs:61-62`) — per-instance settable property seeded from the env var, not a shared static diagnostics-owner class | +| `ACDREAM_DUMP_SURFACES` | `="1"` (ordinal) | One-shot (per session) surface-format histogram dump for the atlas-opportunity audit — fires once after `_dumpFrameCounter>=600` OnRender ticks AND `_uploadMetadata.Count>=100` uploaded textures; writes to the host diagnostics directory | Doc comment claims "Zero cost when off" but `_uploadMetadata[name]=(w,h,fmt)` (`TextureCache.cs:1042`) is written **unconditionally on every texture upload regardless of the flag** — real (small) always-on dictionary-write cost. `TickSurfaceHistogramDumpIfEnabled` also re-reads `Environment.GetEnvironmentVariable` every OnRender frame (not cached) until the one-shot fires. Dump-write failures are caught and logged to stderr, not fatal. | unset (off) | `TextureCache` (`TextureCache.cs:102-113` fields, gate at `TextureCache.cs:802-812`, dump at `TextureCache.cs:814-829`), Phase N.6 slice 1 | +| `ACDREAM_FRAME_PROF` | `=1` | master toggle for the frame profiler: CPU frame time, GPU time samples, per-stage CPU attribution, per-frame alloc/GC, `[frame-prof]` report every ~5 s (doc: "permanent apparatus ... do not strip with session probes") | when on, samples `GC.GetAllocatedBytesForCurrentThread()` and stage-scope timing every frame (cheap, by design); its own XML doc claims a GPU-query self-disable tied to `ACDREAM_WB_DIAG=1` that `FrameProfiler.cs` says no longer exists — see Notes #1; startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.FrameProfEnabled` / `FrameProfiler` | +| `ACDREAM_PROBE_ENVCELL` | `=1` | emits one `[envcells]` line per indoor frame: `CellsRendered`/`TrianglesDrawn` + ourBldgs/otherBldgs/filter counts (phase a8 relic; its own render pass was removed but the probe was kept) | print-only; implicitly turned on whenever `ACDREAM_PROBE_VIS` is on (getter is `_probeEnvCellEnabled \ | \ | `RenderingDiagnostics.ProbeEnvCellEnabled` (backing field OR'd with `ProbeVisibilityEnabled`) | +| `ACDREAM_PROBE_INDOOR_ALL` | `=1` | master switch that reads as AND / writes as cascade across Walk, Lookup, Upload, Xform, Cull | print-only (every underlying probe is print-only); startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.IndoorAll` (cascades to the 5 flags below) | +| `ACDREAM_PROBE_INDOOR_CULL` | `=1` (also set by `ACDREAM_PROBE_INDOOR_ALL=1`) | emits `[indoor-cull]` per culled cell entity with cull reason (visibleCellIds-miss / frustum / landblock) | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorCullEnabled` | +| `ACDREAM_PROBE_INDOOR_LOOKUP` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-lookup]` per visible cell entity/sec: render-data hit/miss, IsSetup, parts-hit/parts-miss tallies | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorLookupEnabled` | +| `ACDREAM_PROBE_INDOOR_UPLOAD` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-upload]` requested/completed lines per EnvCell id at `WbMeshAdapter`'s staged-drain time | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorUploadEnabled` | +| `ACDREAM_PROBE_INDOOR_WALK` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-walk]` per visible cell entity/sec: world position, parent cell, landblock/AABB-visible flags, "drew" flag | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorWalkEnabled` | +| `ACDREAM_PROBE_INDOOR_XFORM` | `=1` (also via `ACDREAM_PROBE_INDOOR_ALL`) | emits `[indoor-xform]` per visible cell entity/sec: cell-geometry SetupPart's composed world-matrix translation | print-only; startup-only (its DebugPanel/DebugVM mirror is unreachable — #434) | false (off) | `RenderingDiagnostics.ProbeIndoorXformEnabled` | +| `ACDREAM_PROBE_LOGIN_FRAMES` | `="1"` | Per-completed-frame login/portal-wormhole presentation classification (`world`/`tunnel`/`black`/`void`); logs `[login-frames]` on each classification transition | print-only. "Not a user setting; not in RuntimeOptions; not persisted" (doc comment). | unset (off) | `RenderPresentationDiagnostics.ProbeLoginFrames` (`LoginPresentationFrameProbe.cs:28-29`), consumed by `LoginPresentationFrameProbe.Process` | +| `ACDREAM_PROBE_NET` | `="1"` | Emits `[net-out]` (per outbound reliable message), `[net-tick]` (1 Hz WorldSession.Tick summary incl. reliable-transport rates), `[net-final]` (cumulative stats at Dispose), and `[cmd-gate]` (generation-gated command rejections) | print-only. Doc comment: "the counters themselves increment unconditionally in `TransportStats`; only the string work is gated" — i.e. the underlying stats tracking has a small always-on cost independent of this flag, but this flag itself gates only string/console formatting. | unset (off) | `NetDiagnostics.ProbeNet` (`NetDiagnostics.cs:56-57`), issue #260 probe family | +| `ACDREAM_PROBE_RESOLVE` | `=1` | gates one structured `[resolve]` line per `PhysicsEngine.ResolveWithTransition` call (in/target/out position+cell, ok-vs-partial, grounded/contact status, wall normal, walkable-polygon validity, responsible entity) (l.2a slice 1, general-purpose resolver probe) | print-only, ~30 Hz per moving entity while on | off | `PhysicsDiagnostics.ProbeResolveEnabled` | +| `ACDREAM_PROBE_REVEAL` | `="1"` | While a reveal destination's composite warmup is incomplete, emits one `[composite-warmup]` line/second: pending queue depth, scan state, upload-budget gate, first few unresolved GfxObj ids | print-only | unset (off) | `NetDiagnostics.ProbeReveal` (`NetDiagnostics.cs:115-116`), issue #260 | +| `ACDREAM_PROBE_REVEAL_TIMING` | `="1"` | Wall-clock attribution of each login/portal reveal hold: `[reveal-timing]` lines for `begin`/first-true readiness edges (render/composites/collision/gate/materialized) with elapsed ms, 1 Hz progress, and one `SUMMARY` line at viewport reveal | print-only; doc comment: "never constructed unless the probe env is set, changes no behavior, and costs one branch per `Evaluate` poll otherwise" (i.e. genuinely near-zero cost when off — confirmed, `RevealTimingProbe` object itself is null when disabled) | unset (off) | `StreamingDiagnostics.ProbeRevealTiming` (`StreamingDiagnostics.cs:65-66`), consumed by `LandblockPresentationPipeline.cs:69`, `PublicationTimingProbe.cs:34`, `RevealTimingProbe.cs` (construction gated) | +| `ACDREAM_PROBE_SOUND_WIRE` | `="1"` | One line per inbound server Sound event (`0xF750`) and per wire-sound play decision, with the drop reason when nothing plays — used to determine whether missing interior soundscapes are server- or client-side | print-only, consumed at `AudioHookSink.cs:159` and `EntityEffectController.cs:123` | unset (off) | `AudioDiagnostics.ProbeWireSoundsEnabled` (`AudioDiagnostics.cs:20-21`) | +| `ACDREAM_PROBE_USEABILITY_FALLBACK` | `=1` | gates a per-call log of `IsUseableTarget` calls that take the null-useability fallback path (creature/door/lifestone passes) (measures a real ace-vs-retail data gap, not a bug investigation) | print-only; measures how often ACE ships entities without `_useability` set | off | `PhysicsDiagnostics.ProbeUseabilityFallbackEnabled` | +| `ACDREAM_PROBE_VIS` | `=1` | emits `[vis]` line on root-cell CHANGE: visible cell ids, OutsideView poly/plane counts, per-cell plane counts, scissor-fallback count (phase u.2d repurposed the flag; its DebugPanel mirror is unreachable — #434) | print-only; ALSO implicitly enables the separate `ACDREAM_PROBE_ENVCELL` probe (its getter ORs with this flag — see Notes #3); startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.ProbeVisibilityEnabled` | +| `ACDREAM_REMOTE_VEL_DIAG` | `=1` | prints per-UM/per-tick remote-velocity and animation-cycle diagnostic lines; `Runtime/Physics/RemoteMotion.cs` carries diagnostic-only fields (`PrevServerPos`, `PrevServerPosTime`, `MaxRootMotionSpeedSinceLastUP`, `LastOmegaDiagLogTime`) unconditionally on every remote — small fixed per-instance memory regardless of the flag, not gated (long-lived remote-velocity/animation diagnostic, commit a.1) | print-only, but the raw-site reads in `LiveEntityNetworkUpdateController.cs` fire on every UM/tick even when off (rule-5 violation, `Environment.GetEnvironmentVariable` call per event, 6+ call sites) | off | THREE readers: `AnimationPresentationDiagnostics.FromEnvironment()` (App owner record, cached at startup, consumed by `LiveEntityAnimationPresenter` for `[SEQSTATE]`/`[CURRNODE]`/other part-diagnostic lines, throttled to 1/sec/entity) + raw `Environment.GetEnvironmentVariable` reads scattered across `LiveEntityNetworkUpdateController.cs` (6+ sites: `[UM_RAW]`, `[FWD_WIRE]`, `[VEL_DIAG]`, `[UPCYCLE_SRC]`, `[UM_STALE]`) + `RemoteServerControlledVelocityCycle.cs:68` (`[UPCYCLE]`) | + +## Temporary probes + +Each row names the issue that owns it. **A temporary probe is deleted in +the same commit as its investigation's fix** — if you find one here whose +issue is closed, the strip was missed; delete both. + +> **Probe debt, measured 2026-08-24:** 64 temporary probes exist. They cite +> 21 distinct issues, and **14 of those are already closed** — 17 rows below +> are apparatus whose investigation ended without the strip. A further 14 +> rows name no owning issue at all, which is worse: nobody can tell when +> they are safe to remove. Tracked as [#435](ISSUES.md); do not add to the +> pile. Every probe here still costs a branch on its hot path even when +> unset, and a handful re-read the environment per frame rather than +> caching (see their side-effects column). + +| Flag | Owning investigation | Value | What it does | Side effects | Read by | +|---|---|---|---|---|---| +| `ACDREAM_A8_DUMP_PV` | (unattributed) | `=1` | Dumps local→NDC→clipped portal geometry (first 2 `Build` calls per distinct camera cell) | print-only (`Console.WriteLine`) | `PortalVisibilityBuilder.cs:270-271` (static field, not a diagnostics-owner class) | +| `ACDREAM_AIRBORNE_DIAG` | #42 | `=1` | prints `[SWEEP]`/`[SWEEP-OBJ]` lines tracing airborne-sweep XY drift, only when `!isOnGround` | print-only; re-reads the env var (`Environment.GetEnvironmentVariable`) on every airborne resolve/candidate instead of caching — minor per-call overhead when the flag is unset too | raw reads in `PhysicsEngine.cs:2300` + `TransitionTypes.cs:3905` (issue #42) | +| `ACDREAM_CLIP_DEBUG` | #176 | `=1` | forces the EnvCell SHELL pass to map every instance to clip slot 0 (no-clip) instead of its cell's portal-slice region | ALTERS RENDERED OUTPUT: shells draw whole/unclipped instead of trimmed — a visual isolation mode, not a log-only probe; no DebugPanel mirror | `RenderingDiagnostics.ClipDebugNoShellTrim` | +| `ACDREAM_DUMP_APPEARANCE` | #5 | `="1"` | Logs every `0xF625` ObjDescEvent + `0xF7DB` UpdateObject with body length, target guid, hex preview — used to debug remote-player appearance asymmetry | print-only (`Console.WriteLine`) | `WorldSession` static field `DumpAppearanceEnabled` (`WorldSession.cs:792-793`), raw scattered read, issue #5 diagnostic | +| `ACDREAM_DUMP_CELLS` | #98 | `=` | one-shot JSON dump of any cached EnvCell whose id matches the list, to `ProbeDumpCellsPath` (issue #98 fixture capture) | file I/O once per matching cell id (no-op on repeat); fixture-generation tool, not a perf-neutral no-op when ids are listed | `PhysicsDiagnostics.ProbeDumpCellIds` (`ParseHexIdList`) | +| `ACDREAM_DUMP_CELLS_DIR` | (unattributed) | `=` | overrides the output directory for `ACDREAM_DUMP_CELLS` | print/file-path only; no effect unless `ACDREAM_DUMP_CELLS` is also set | `PhysicsDiagnostics.ProbeDumpCellsPath` | +| `ACDREAM_DUMP_CLOTHING` | (unattributed) | `=1` | Print-only: dumps clothing/part-swap diagnostics for a spawned entity when its setup has ≥10 mesh parts (humanoids). Gated additionally on part count even when the flag is on. | `print-only` | `RuntimeOptions.DumpClothing` → `DatLiveEntityProjectionMaterializer.cs:251-258,1190` | +| `ACDREAM_DUMP_EDGE_SLIDE` | (unattributed) | `=1` | gates five `edge-slide:` trace lines (stepdown-failed, stepdown-branch-enter, phase2, branch, cliffslide) inside the L.4-diag edge-slide/cliff-slide code path | print-only; property re-reads `Environment.GetEnvironmentVariable` on EVERY call (not cached in a field) — repeated env lookups during edge-slide resolution when active; raw read outside any diagnostics-owner class (rule-5 candidate) | `Transition.DumpEdgeSlideEnabled` (private expression-bodied property in `TransitionTypes.cs`, raw read) | +| `ACDREAM_DUMP_ENTITY` | #119 | comma-separated hex ids, optional `0x` prefix, malformed segments ignored | per-entity HYDRATE/DRAW/WALK-REJECT trace for a watchlist of Setup/GfxObj source ids across `LandblockBuildFactory`, `WbDrawDispatcher` | print-only; every call site fast-exits on `Count==0`. The same id set is ALSO reused (undocumented in its own XML doc) as the watchlist for the `ACDREAM_PROBE_OUTSTAGE` `[outstage-own]` per-entity verdict probe — see Notes | `RenderingDiagnostics.DumpEntitySourceIds` | +| `ACDREAM_DUMP_GFXOBJS` | #98 | `=` | one-shot JSON dump of any cached GfxObj's polygon table + BSP root metadata matching the list, to `ProbeDumpGfxObjsPath` (issue #98 fixture capture) | file I/O once per matching id (no-op on repeat) | `PhysicsDiagnostics.ProbeDumpGfxObjIds` (`ParseHexIdList`) | +| `ACDREAM_DUMP_GFXOBJS_DIR` | (unattributed) | `=` | overrides the output directory for `ACDREAM_DUMP_GFXOBJS` | print/file-path only; no effect unless `ACDREAM_DUMP_GFXOBJS` is also set | `PhysicsDiagnostics.ProbeDumpGfxObjsPath` | +| `ACDREAM_DUMP_LIVE_SPAWNS` | (unattributed) | `=1` | Print-only: logs every live `CreateObject` spawn as it's processed, plus DROP lines when a setup dat id is missing. | `print-only` | `RuntimeOptions.DumpLiveSpawns` → `DatLiveEntityProjectionMaterializer.cs:161-226`, `SessionPlayerComposition.cs:566,712` | +| `ACDREAM_DUMP_MOVE_TRUTH` | (unattributed) | `=1` | Print-only: records the local player's last outbound movement wire truth (position, cell, contact byte, velocity) for comparing what was actually sent vs. local physics state. Early-returns with zero cost when disabled. | `print-only` | `RuntimeOptions.DumpMoveTruth` → `GameWindow.cs:795` → `MovementTruthDiagnosticController` | +| `ACDREAM_DUMP_OPCODES` | #5 | `="1"` | Logs first occurrence of each genuinely-unhandled inbound opcode (deduped by opcode) | print-only. Must stay the LAST else-if in the dispatch chain per comment (else it would intercept handled opcodes) — currently correct. | `WorldSession` static field `DumpOpcodesEnabled` (`WorldSession.cs:788-789`, consumed `WorldSession.cs:2391-2398`), issue #5 diagnostic. Also mirrored (display-only, non-functional) via `DebugPanel.cs:241`/`DebugVM.cs:227`. | +| `ACDREAM_DUMP_SCENERY_Z` | #48 | `=1` | Per-spawn Z-placement diagnostic for procedural scenery (trees/bushes/rocks), added for issue #48 (the "trees-in-sky" bug). | **NOT print-only** — this is a real behavior fork, not just added logging. `LandblockBuildFactory.cs:167-178`: when the flag is on, the streaming worker calls a **separate, duplicate scenery-building method** (`BuildSceneryEntitiesForStreaming`, a full parallel reimplementation of GfxObj/Setup mesh resolution + placement inline in this file) instead of production's `LandblockPhysicsContentBuilder.HydrateProceduralScenery`. Any visual/measurement run taken with this flag set is exercising a different scenery-placement code path than production, which can drift from it silently. | `RuntimeOptions.DumpSceneryZ` → `SessionPlayerComposition.cs:280` → `LandblockBuildFactory.cs:23,42,168,335` | +| `ACDREAM_DUMP_SKY` | (unattributed) | `=1` | Print-only: dumps decoded `SkyDesc` raw values on region load (`SkyDescLoader.cs`) and per-GfxObj `Surface.Type`/translucency flags on first upload (`SkyRenderer.cs`), plus gates a `TimeSync` console diagnostic in `GameWindow`. Built to resolve specific open questions about retail sky units and GfxObjReplace timing (2026-04-23 research), now answered but the dumps remain wired. | Three independent reads of the SAME env var, only one of which (`RuntimeOptions.DumpSky`) goes through the typed options object; the other two are raw scattered reads (see Notes). `SkyRenderer.cs:582`'s raw read is in the App layer and has no architectural excuse for bypassing `RuntimeOptions` — `_options.DumpSky` was already available to that composition. `print-only` in all three sites. | `RuntimeOptions.DumpSky` (typed) → `GameWindow.cs:704` (`TimeSyncDiagnostic`); **also** two independent raw `Environment.GetEnvironmentVariable` reads at `SkyDescLoader.cs:392` (Core) and `SkyRenderer.cs:582` (App) | +| `ACDREAM_DUMP_STEEP_ROOF` | (unattributed) | `=1` | gates `[steep-roof] KILL-VELOCITY-APPLIED` in `PhysicsEngine.ResolveWithTransition` when retail's `kill_velocity` zeroes body velocity on steep-slope impact, plus per-frame plane-normal traces in `TransitionTypes`/`PlayerMovementController` | print-only | `PhysicsDiagnostics.DumpSteepRoofEnabled` | +| `ACDREAM_DUMP_STEPUP` | (unattributed) | `=1` | prints `stepup: enter normal=… verdict=WALKABLE/STEEP …` on every step-up attempt | print-only; raw per-call `Environment.GetEnvironmentVariable` read outside a diagnostics-owner class (rule-5 violation); content is mirrored (not replaced) into the buffered `[transit-fail-stepup]` trace gated separately by `ACDREAM_DUMP_TRANSIT_FAIL` | raw read in `Transition.DoStepUp` (`TransitionTypes.cs:5991`, re-read every call — not cached) | +| `ACDREAM_DUMP_TRANSIT_FAIL` | #345 | `=1` | buffers per-tick `[transit-fail-insert]`/`[transit-fail-stepup]`/`[transit-fail-walk]`/`[transit-fail-adjust]` trace lines into a `[ThreadStatic]` list and flushes them to console ONLY when a tick requested nonzero XY movement but delivered zero (self-selecting "stuck tick" predicate) | print-only, zero allocation when off (flag checked before touching any buffer per its own doc); buffer/list allocation only on ticks that are already stuck | `PhysicsDiagnostics.DumpTransitFailEnabled` | +| `ACDREAM_DUMP_VENDOR` | (unattributed) | `="1"` | `[vendor-diag]`-prefixed trace across ~25 call sites for two live-only vendor regressions: Chain A (far-click walk-to-use approach never opens the shop window) and Chain B (splittable vendor stack selection shows no quantity slider) | print-only (`Console.WriteLine`), verified true no-op when unset | `VendorDiagnostics.DumpVendorEnabled` (`VendorDiagnostics.cs:25-26`) — a proper diagnostics-owner class per Code Structure Rule 5, shared across App/Core.Net/Runtime | +| `ACDREAM_DUMP_VITALS` | (unattributed) | `="1"` | Logs every `PrivateUpdateVital(Current)` parse, every parsed `PlayerDescription` (vector flags/attr/spell counts), and `PlayerDescriptionParser` trailer/mid-walk `FormatException` failures with position | print-only at every site. `PlayerDescriptionParser.cs:458/473` re-read the env var raw inside `catch` blocks on every parse failure (rare, but scattered/uncached). | Read independently (not shared) at 4 sites: `WorldSession.cs:790-791` (`DumpVitalsEnabled`), `GameEventWiring.cs:1041` (local `dumpPd` at PlayerDescription registration), `PlayerDescriptionParser.cs:458` and `:473` (per-catch-block raw reads). Also mirrored (display-only, non-functional) via `DebugPanel.cs:240`/`DebugVM.cs:225`. | +| `ACDREAM_HIDE_PART` | (unattributed) | `=` | Hides one mesh part by index on entities with ≥10 parts (humanoids) — a debugging aid for equipment/clothing part-visibility issues. | Real (visible) behavior change, not print-only, but scoped to a single diagnostic index and off by default. | `RuntimeOptions.HidePartIndex` → `LivePresentationComposition.cs:608` → `LiveEntityAnimationPresenter.cs:21,38,243` | +| `ACDREAM_LIGHT_DEBUG` | #176 | `=` (`int.TryParse`; unset/invalid → 0) | shader isolation mode uploaded as `uLightDebug` by `EnvCellRenderer` + `WbDrawDispatcher`: 0=off, 1=ambient-only vertex lighting, 2=kill dynamic point lights, 3=raw vLit visualization (texture ignored) | ALTERS RENDERED OUTPUT directly every draw pass (changes fragment-shader lighting/texturing) — not a log probe; no DebugPanel mirror | `RenderingDiagnostics.LightDebugMode` | +| `ACDREAM_PROBE_AUTOWALK` | issue #63 | `=1` | gates `[autowalk-out]`/`[autowalk-mt]`/`[autowalk-up]` lines in `LiveEntityNetworkUpdateController` tracing local-player server-initiated auto-walk (`SendUse`/`SendPickUp`, inbound `UpdateMotion`, inbound `UpdatePosition`) | print-only; filtered to local player only, low volume | `PhysicsDiagnostics.ProbeAutoWalkEnabled` | +| `ACDREAM_PROBE_BUILDING` | l.2d slice 1 | `=1` | gates the multi-line `[resolve-bldg]` BSP-shadow-hit trace in `TransitionTypes.FindObjCollisions`, one-time `[entity-source]` registration logs in `GameWindow`, `[door-cycle]` UM dispatch trail, and a one-shot `[setstate-hex]` wire dump of the first `SetState` (0xF74B) packet in `WorldSession` | print-only; also un-gates the `PhysicsDiagnostics.LastBspHitPoly` diagnostic side-channel (a static field write in `BSPQuery`/`FlatBspQuery`, read back by the `[resolve-bldg]` line) — no gameplay effect, but an extra static-field write per BSP hit while on; heavy output (one multi-line entry per BSP hit per physics tick) | `PhysicsDiagnostics.ProbeBuildingEnabled` | +| `ACDREAM_PROBE_CELL` | (unattributed) | `=1` | gates one `[cell-transit]` line per `PlayerMovementController.CellId` change (old→new cell, position, reason tag) | print-only; low volume (only on actual cell crossings) | `PhysicsDiagnostics.ProbeCellEnabled` | +| `ACDREAM_PROBE_CELLSET` | a6.p5 | `=1` | gates `PhysicsDiagnostics.LogCellSetBuild`, one `[cellset-build]` line per `BuildCellSetAndPickContaining` call (seed cell, sphere XY, candidate list) from `CellTransit.cs:1468` | print-only; builds a `StringBuilder` of the candidate id list only when the flag is on | `PhysicsDiagnostics.ProbeCellSetEnabled` | +| `ACDREAM_PROBE_CELL_CACHE` | indoor walking phase d | `=1` | gates one `[cell-cache]` line per EnvCell first-cached in `PhysicsDataCache.CacheCellStruct` (poly counts, BSP root structure) | print-only; fires at most once per EnvCell (cache is no-op after first population); no DebugPanel mirror | `PhysicsDiagnostics.ProbeCellCacheEnabled` | +| `ACDREAM_PROBE_CHILD_CELL` | c4 route 7 | `=1` | gates one `[child-cell]` line per Runtime committed-child canonical-cell write in `RuntimeLiveEntitySessionController`, `RuntimeEntityObjectLifetime`, `RuntimeEntityDirectory` (parent/child guid, old/new cell, cause tag) | print-only | `PhysicsDiagnostics.ProbeChildCellEnabled` | +| `ACDREAM_PROBE_CLIPROUTE` | "throwaway apparatus — strip once §4 ships" | `=1` | print-on-change `[clip-route]` / `[clip-route-disp]` / `[clip-route-scis]` lines: outside-slice clip routing, region-SSBO bytes, terrain-UBO head, actual GL/RHI scissor state | print-only | `RenderingDiagnostics.ProbeClipRouteEnabled` | +| `ACDREAM_PROBE_CONTACT_PLANE` | spike-only, 2026-05-20 | `=1` | gates one `[cp-write]` line per write to `CollisionInfo.ContactPlane*`/`LastKnownContactPlane*` fields (field, old→new, caller method via stack walk, source line); only logs on actual value changes | print-only, but performs a stack walk to identify the caller method when firing — real CPU cost per write while on (not just a string format); suppresses no-op writes to bound volume | `PhysicsDiagnostics.ProbeContactPlaneEnabled` | +| `ACDREAM_PROBE_ENT` | #138 | `="1"` | Traces the persistent player entity across teleport streaming churn: presence in the render draw-set flat view vs. survival of the dynamics cull, to distinguish "missing from draw set" vs "present but culled" | print-only, "Observation-only — emits no behavior change" (doc comment). `LogPlayerDynOnChange` dedupes by transition to avoid per-frame spam. Marked STRIP-once-root-caused (like the dense-town FPS apparatus). | `EntityVanishProbe.Enabled` (`EntityVanishProbe.cs:23-24`), issue #138-B | +| `ACDREAM_PROBE_FLAP` | "throwaway apparatus — strip once the flap mechanism is confirmed" | `=1` | EVERY FRAME (unthrottled, not change-gated) while the camera root is indoor: `[flap]` from `PortalVisibilityBuilder.Build` (portal side-test/traverse/cull/projection) + paired `[flap-cam]` from `PhysicsCameraCollisionProbe`/`[flap-sweep]` (FindCameraCell resolution, eye positions) | print-only, but unthrottled per-frame `StringBuilder` allocation + `Console.WriteLine` on multiple call sites while indoor — heavy log volume/allocation under sustained indoor play; does not alter rendered output | `RenderingDiagnostics.ProbeFlapEnabled` | +| `ACDREAM_PROBE_GLSTATE` | "throwaway apparatus — strip once §4 ships" | `=1` | print-on-change `[gl-state]` line: depth/blend/cull/scissor/viewport/draw-FBO/color-mask/`glGetError` snapshot | print-only per its docstring; the actual state-snapshot/comparison call site lives outside `RenderingDiagnostics.cs` and was outside this pass's cited read sites | `RenderingDiagnostics.ProbeGlStateEnabled` | +| `ACDREAM_PROBE_INDOOR_BSP` | indoor walking phase 1 / cellar-lip wedge | `=1` | gates `[indoor-bsp]` (per `BSPQuery.FindCollisions` indoor call), `[neg-poly]` (near-miss polygon detail in `BSPQuery`), and `[stepdown-decide]` (step-down accept/reject inputs in `TransitionTypes`) trace lines | print-only; also un-gates the `LastBspHitPoly` diagnostic side-channel write (same as `ACDREAM_PROBE_BUILDING`) | `PhysicsDiagnostics.ProbeIndoorBspEnabled` | +| `ACDREAM_PROBE_INDOOR_LIGHT` | #176/#177 discriminator, a7.l1 | `=1` | rate-limited (1 Hz) `[indoor-light]` line from `LightManager.BuildPointLightSnapshot`: point-light pool set composition (pool/cellLess/registered/capped/byCell histogram) | print-only, explicitly "inert unless set" per the call-site comment (LightManager.cs:368-370); no DebugPanel mirror | `RenderingDiagnostics.ProbeIndoorLightEnabled` | +| `ACDREAM_PROBE_JUMP` | campaign ch round 2 | `=1` | gates the `[jump]` line in `PlayerMovementController.ReportJumpRefusal`, printed UNCONDITIONALLY (even when `OnInterfaceText` is null) to distinguish "branch never fired" from "branch fired, callback dropped it" | print-only; `Headless/Policies/HeadlessBotPolicy.cs`'s `JumpProbeHeadlessBotPolicy` doc comment references this flag as a companion but does not itself read it — it is a headless bot behavior meant to be run alongside `ACDREAM_PROBE_JUMP=1`, not a second consumer | `PhysicsDiagnostics.ProbeJumpEnabled` | +| `ACDREAM_PROBE_LIGHT` | #133 a7 | `=1` | rate-limited (1 Hz) `[light]` line + up to 3 `[light-detail]` lines: scene ambient/sun/registered/active light counts and nearest active point/spot light detail | print-only ("Output-only, inert when off" per doc) | `RenderingDiagnostics.ProbeLightEnabled` | +| `ACDREAM_PROBE_LOCAL_TELEPORT` | c4 route 3 d-t8 | `=1` | gates one `[local-tp]` line per local-player portal-arrival attempt (committed AND refused) from `LocalPlayerTeleportController` and `RuntimeAcceptedPositionDriveController.LogPortalArrivalAttempt` — the single Runtime chokepoint both graphical and headless hosts share | print-only; dual-host parity evidence (same line shape from both hosts) | `PhysicsDiagnostics.ProbeLocalTeleportEnabled` | +| `ACDREAM_PROBE_OUTSTAGE` | #131 | `=1` | print-on-change `[outstage]` line (outside-stage routing + per-slice cone verdicts) from `RetailPViewRenderer`; plus, when `ACDREAM_DUMP_ENTITY` also names watched ids, `[outstage-own]` per-entity PASS/CULL lines | print-only | `RenderingDiagnostics.ProbeOutStageEnabled` | +| `ACDREAM_PROBE_PARK` | issue #309 | `=1` | gates `[park]`/`[park-restore]` lines when a `RuntimeSetPositionState` placement parks or a cancelled park's withdrawal is rolled back | print-only, low volume (parks are rare); in a MULTI-session headless host, `HeadlessStaticStateAudit.ValidateProcessIsolation` THROWS `HeadlessConfigurationException` at startup if this (or any other process-global `Probe*`/`Dump*` boolean, `CollisionShadowSampleEvery`, or `PhysicsResolveCapture`) is enabled — refusal is waived only when `sessionCount==1` (logs loudly and proceeds instead) | `PhysicsDiagnostics.ProbeParkEnabled` | +| `ACDREAM_PROBE_PHANTOM` | #113, "throwaway apparatus — strip when the phantom closes" | `=1` | print-on-change `[phantom-shell]` / `[phantom-objs]` lines identifying which draw mechanism (shell pass vs. entity list) draws geometry unclipped/un-viewcone'd per cell | print-only | `RenderingDiagnostics.ProbePhantomEnabled` | +| `ACDREAM_PROBE_PLACEMENT_FAIL` | issue #98 | `=1` | gates one `[place-fail]` line per Path-1 (Placement/Ethereal) `Collided` return in `BSPQuery.FindCollisions`, plus one per `Transition.DoStepDown` placement-insert rejection | print-only; low volume (fires only on actual rejection) | `PhysicsDiagnostics.ProbePlacementFailEnabled` | +| `ACDREAM_PROBE_POLY_DUMP` | a6.p3 slice 4, issue #98 | `=1` | gates one `[poly-dump]` line (full polygon geometry: cell, poly index, sides, plane, all vertices) per `AdjustSphereToPlane` push-back call | print-only; HEAVY output (one full-geometry dump per push-back call) — doc explicitly says "use briefly, then turn off" | `PhysicsDiagnostics.ProbePolyDumpEnabled` | +| `ACDREAM_PROBE_PORTAL_CHURN` | "throwaway apparatus — strip once the bound ships" | `=1` | one `[portal-churn]` summary per `PortalVisibilityBuilder.Build` call: per-cell pop/re-pop counts, re-enqueue totals, reciprocal-clip pre→post region growth | print-only | `RenderingDiagnostics.ProbePortalChurnEnabled` | +| `ACDREAM_PROBE_PUSH_BACK` | phase a6.p1 | `=1` | gates `[push-back]` (`BSPQuery.AdjustSphereToPlane`), `[push-back-disp]` (`BSPQuery.FindCollisions` 6-path dispatcher), `[push-back-cell]` (`Transition.CheckOtherCells` multi-cell BSP) lines | print-only; the `DebugVM.cs:380` "runtime mirror" is dead code — `DebugVM`/`DebugPanel` (`AcDream.UI.Abstractions/Panels/Debug/`) are never instantiated anywhere in `src/` (the ImGui frontend they required was removed at Campaign V slice V11); only the startup env var takes effect | `PhysicsDiagnostics.ProbePushBackEnabled` | +| `ACDREAM_PROBE_PVINPUT` | "throwaway apparatus — strip once the jitter source is pinned" | `=1` | one `[pv-input]` line/frame with 6-dp-precision `PortalVisibilityBuilder.Build` inputs (camera eye, player position, VP elements) + resulting flood-cell count; deliberately runs WITHOUT the heavier `[flap]` probe so the log stays diffable | print-only | `RenderingDiagnostics.ProbePvInputEnabled` | +| `ACDREAM_PROBE_REACH` | #334, temporary — strip with the probe family | `=1` | gates `[reach-q]` (per-cell candidate-disposition query summary, emitted even on zero-entry cells) and `[reach-obj]` (per-candidate disposition: exempt/no-shape/bsp-only-skip/tested) lines in `Transition.FindObjCollisionsInCell` | print-only; runs on a HOT path (per cell per transitional insert); de-duplicated via two `Dictionary` caches with a `lock`-protected gate (`_reachGate`) — a real per-call dictionary lookup + occasional lock contention while on, bounded emission (≤2/sec per cell, ≤1/sec per candidate) | `PhysicsDiagnostics.ProbeReachEnabled` | +| `ACDREAM_PROBE_REMOTE_LANDING` | bug a / issue #32, temporary | `=1` | gates `[remote-landing]`/`[remote-landing-gate]`/`[remote-landing-after]` lines around remote ground-contact edges in `LiveEntityNetworkUpdateController` and `RuntimeRemotePhysicsUpdater`; `MotionTableDispatchSink.ApplyMotion` unconditionally forwards its result to `PhysicsDiagnostics.RecordRemoteLandingDispatch` (self-guarded internally, no behavior change) | print-only; uses `[ThreadStatic]` capture latches (`_remoteLandingApplyCalls` etc.) so a headless host ticking several sessions in parallel doesn't cross-contaminate | `PhysicsDiagnostics.ProbeRemoteLandingEnabled` | +| `ACDREAM_PROBE_REMOTE_SLIDE` | bug b, temporary — strip once two-client roof capture lands | `=1` OR `=` | gates `[remote-slide-up]`/`[remote-slide-vec]`/`[remote-slide-snap]`/`[remote-slide-enq]` lines across `LiveEntityNetworkUpdateController`, `InterpolationManager`, `RuntimeRemotePhysicsUpdater`, `RuntimeRemoteSteadyStatePosition` tracing two candidate remote-slide "blip" producers | print-only; `BeginRemoteSlideAttribution`/GUID-stamping calls are UNCONDITIONAL at several call sites (self-guard is internal), so a `[ThreadStatic]` field write happens on every remote tick regardless of the flag (cheap, non-allocating); a GUID allow-list narrows output to specific entities for a readable two-client capture | `PhysicsDiagnostics.ProbeRemoteSlideEnabled` + `ProbeRemoteSlideGuids` (raw string parsed via `ParseHexIdList` unless it's the literal `"1"`) | +| `ACDREAM_PROBE_REMOTE_TELEPORT` | c4 route 4b-3, temporary | `=1` | gates one `[remote-teleport]` line per routed remote teleport arm in `LiveEntityNetworkUpdateController.ApplyRemoteContactRouting` | print-only; a 2026-08-04 fix moved the enabled-check to the CALL SITE because the probe's internal self-guard did not prevent `teleportStatus.ToString()` from being evaluated/allocated on every teleport regardless of flag state — now properly guarded | `PhysicsDiagnostics.ProbeRemoteTeleportEnabled` | +| `ACDREAM_PROBE_SEAMDRAW` | #176, "throwaway apparatus" | `"1"`/`"true"`/blank → default #176 Facility Hub cell set (7 fixed hex ids); otherwise comma-separated hex cell-id list | change-deduped + 2 s-heartbeat `[seam-cell]`/`[seam-snap]`/`[seam-ent]`/`[seam-mask]` lines from `EnvCellRenderer.Render` and `WbDrawDispatcher` describing per-instance transforms and resolved light-set identities at target cells | print-only | `RenderingDiagnostics.ProbeSeamDrawEnabled` / `SeamDrawTargetCells` | +| `ACDREAM_PROBE_SHELL` | #78, "throwaway apparatus — strip once the indoor-enclosure render is fixed" | `=1` | one `[shell]` line per opaque-pass `EnvCellRenderer.Render` call: per filtered cell — snapshot presence, gfxObj/batch/index/translucent/zero-bindless-handle counts | print-only; allocates a `StringBuilder` and loops every visible cell on every opaque pass while enabled | `RenderingDiagnostics.ProbeShellEnabled` | +| `ACDREAM_PROBE_STEP_HEIGHTS` | issue #338 | `=1` | gates edge-triggered `[step-h]` lines at `prepare`/`publish`/`resolve` sites tracing step-up/step-down height provenance | print-only; `AnnounceStepHeightProbeOnce` prints TWO self-report lines EXACTLY ONCE PER PROCESS regardless of the flag's value (reports the flag's own state + the raw env var text + the running assembly's file path) — this self-report line is NOT gated by the flag itself, only rate-limited to once | `PhysicsDiagnostics.ProbeStepHeightsEnabled` | +| `ACDREAM_PROBE_STEP_WALK` | a6.p3 issue #98 | `=1` | gates `[step-walk]` lines at select points in the transition sub-step loop and step-down probe (requested vs adjusted offset, sphere positions, contact planes, walkable flags) | print-only; no DebugPanel mirror | `PhysicsDiagnostics.ProbeStepWalkEnabled` | +| `ACDREAM_PROBE_STICKY` | r5-v3 issue #171 | `=1` | gates `[sticky]` lifecycle lines (STICK/UNSTICK/LEASE-EXPIRE/TARGET-status teardown) and per-armed-tick steer lines in `AcDream.Core.Physics.Motion.StickyManager`, plus `[sticky-snap-skip]` in `LiveEntityNetworkUpdateController` when a server hard-snap is suppressed for a stuck entity | print-only; heavy while a pack is stuck (~60 Hz × stuck count) | `PhysicsDiagnostics.ProbeStickyEnabled` | +| `ACDREAM_PROBE_SUPPORT` | issue #337, temporary | `=1` | gates `[support]` (per resolve, per body INCLUDING corpses/NPCs — independent terrain sample at the body's out-XY compared against the contact plane's height/provenance) and `[geom]` (once per nearby GfxObj — physics-BSP vertex cloud vs visual mesh AABB coincidence verdict) lines in `PhysicsEngine` | print-only; `[support]` performs an INDEPENDENT terrain height sample every time it fires (real extra computation beyond the resolve itself, throttled to 4 Hz per body plus every 10 cm of vertical movement); pure reads only, never mutates production collision state | `PhysicsDiagnostics.ProbeSupportEnabled` | +| `ACDREAM_PROBE_SWEPT` | phase w stage 0 | `=1` | gates one `[cell-swept]` line per `ResolveWithTransition` call comparing the transition's swept cell vs the legacy static `ResolveCellId` path | print-only | `PhysicsDiagnostics.ProbeSweptEnabled` | +| `ACDREAM_PROBE_TELEPORT` | 2026-06-22, "removable diagnostic" | `=1` | gates `[tp-probe]` lines (`LogTeleport`) at AIM/ENQ/BUILD/APPLY/PLACED teleport-pipeline events across `LocalPlayerTeleportController` and `RuntimeAcceptedPositionDriveController`, with cross-thread monotonic timestamps | print-only | `PhysicsDiagnostics.ProbeTeleportEnabled` | +| `ACDREAM_PROBE_TEXFLUSH` | #105 | `=1` | one `[tex-flush]` line whenever `WbMeshAdapter.Tick`'s staged-texture-update picture changes (pending layer updates before/after the per-frame mipmap flush) | print-only | `RenderingDiagnostics.ProbeTexFlushEnabled` | +| `ACDREAM_PROBE_VIEWER` | #119-residual | `=1` | one `[viewer]` line per CHANGE of (root cell, flood size, OutsideView poly count, player cell), with mm-precision projection eye — capture half of the tower-ascent capture→replay loop (`TowerAscentReplayTests`) | print-only | `RenderingDiagnostics.ProbeViewerEnabled` | +| `ACDREAM_PROBE_WALK_MISS` | issues #83, spike-only | `=1` | gates `[walk-miss]` (per `Transition.TryFindIndoorWalkablePlane` MISS) and `[floor-polys]` (per indoor cell cached, enumerating walkable-eligible polygons) lines | print-only; no DebugPanel mirror | `PhysicsDiagnostics.ProbeWalkMissEnabled` | +| `ACDREAM_WIRE_MESH` | #337, explicitly "temporary" | `=1` | when the separate F2 collision-wireframe overlay is already active, replaces its cheap broadphase-proxy-cylinder drawing with the object's REAL physics-BSP polygon edges (cyan) + visual mesh AABB (magenta) + terrain triangle under the player (yellow), resolved live every frame | ALTERS RENDERED OUTPUT (debug overlay geometry): adds real per-frame physics-BSP polygon extraction + line-drawing cost while F2 is on; only takes effect when the separate F2 toggle (`_state.CollisionWireframesVisible`) is also enabled; also emits a throttled print-on-change stats line | `RenderingDiagnostics.CollisionMeshWireframeEnabled` | +| `ACDREAM_WIRE_RADIUS` | companion knob to #337/`acdream_wire_mesh` | `=` (`float.TryParse`, invariant culture; falls back to 30 if unparsable or ≤0) | sets the radius around the player within which `CollisionMeshWireframeEnabled` resolves polygon geometry | larger radius = more physics-BSP polygon extraction/line-drawing cost per frame; only matters while `ACDREAM_WIRE_MESH=1` | `RenderingDiagnostics.CollisionMeshWireframeRadius` | + +## Deprecated + +| Flag | Value | What it does | Side effects | Default | Read by | +|---|---|---|---|---|---| +| `ACDREAM_DEVTOOLS` | `=1` | logs a one-time "ImGui dev UI removed" notice; the only remaining functional consumer is `VulkanGraphicsContext.cs:184` (`enableOptionalExtensions: _options.DevTools`, selects optional Vulkan validation/debug-utils extensions) | real effect: turns on Vulkan validation/debug-utils extensions (can change perf and can surface validation-layer errors that don't occur when off) — NOT measurement-neutral for a perf gate; `GameWindow.DevToolsEnabled` is a hardcoded `false` const (dead — no ImGui dev UI exists to gate); `DevToolsInputCaptureSource(bool enabled)` explicitly discards its `enabled` ctor arg (`_ = enabled;`) — dead parameter, always reports `WantCaptureKeyboard=false` | off | `RuntimeOptions.DevTools` (typed, `Program.cs`/`RuntimeOptions.Parse`) | +| `ACDREAM_STREAM_RADIUS` | `=` (non-negative) | Legacy override for the streaming near/far radii, applied on top of the quality-preset's radii at session-start composition. | **CLAUDE.md explicitly documents this as "legacy" and warns against using it for measurement.** Confirmed in code (`SessionPlayerComposition.cs:256-259`): `nearRadius = legacyRadius; farRadius = Math.Max(legacyRadius, farRadius)` — it FORCES `NearRadius` and only ever RAISES (never lowers) `FarRadius`. It is set once at session-start composition and is **silently discarded** by any later Settings quality change: `RuntimeSettingsController.ApplyQuality` → `RuntimeSettingsTargets.ApplyQuality` → `StreamingController.ReconfigureRadii` recomputes radii straight from the quality preset with no knowledge of this override. A measurement/gate run taken with this set is measuring a different streaming window than production and than any run that later touches Settings. | `null` → quality-preset radii unmodified (production default: High preset, Near 4 / Far 12) | `RuntimeOptions.LegacyStreamRadius` → `SessionPlayerComposition.cs:254-268` | + +--- + + + +## Retired + +Flags that no longer exist, kept only so a stale script or an old research +document does not send someone hunting. Rows below this marker are exempt +from the "must still exist" check. + +| Flag | Retired | Replacement | +|---|---|---| +| `ACDREAM_RUN_SKILL` | Client-side run-skill override for local motion prediction. Skills now arrive from the server (`LiveMovementStatsApplier`); the hardcoded fallback is 200. | none — server-authoritative | +| `ACDREAM_JUMP_SKILL` | As above. The fallback is 300, not the 200 that CLAUDE.md advertised. | none — server-authoritative | +| `ACDREAM_RENDER_BACKEND` | Selected the GL-vs-Vulkan backend. Campaign V deleted the OpenGL backend; Vulkan is the only one. Two comments still named it as a live co-requisite until 2026-08-24. | none | +| `ACDREAM_ANIM_SPEED_SCALE` | Animation-speed multiplier from the pre-retail-sequencer era; died with the 1.248x factor. | none | +| `ACDREAM_A8_AUDIT` | Phase A8 EnvCell batch/cull audit dump. Its only caller never existed; `EnvCellRenderer.CollectCellAuditLines` was unreachable and was deleted 2026-08-24. | `ACDREAM_PROBE_ENVCELL` | diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs index 49a2543b..57470517 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs @@ -7,8 +7,10 @@ using Silk.NET.Windowing; namespace AcDream.App.Rendering.Gpu.Vk; /// -/// The Vulkan capability-probe and bring-up harness. Reached only when -/// ACDREAM_RENDER_BACKEND=vulkan and ACDREAM_VULKAN_PROBE=1. +/// The Vulkan capability-probe and bring-up harness. Reached when +/// ACDREAM_VULKAN_PROBE=1. (This previously also required +/// ACDREAM_RENDER_BACKEND=vulkan; that variable died with the OpenGL +/// backend at Campaign V and is read nowhere — Vulkan is the only backend.) /// /// What it is for. Answering two questions without starting the /// client: does this machine pass the Vulkan capability gate, and does the RHI diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index a83b7895..16a9e887 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -193,61 +193,6 @@ public sealed partial class EnvCellRenderer : return (poolTotal, hwm); } - /// - /// Phase A8 audit probe (2026-05-28 visual-gate-#1 follow-up). - /// One-shot per (cellId, gfxObjId) pair: dumps batch counts + CullModes + - /// transparency flags + bindless-handle-non-zero status, so the operator - /// can read offline and identify why specific polys (e.g., floors) aren't - /// rendering. Set ACDREAM_A8_AUDIT=1 to enable. - /// Returns a deduplicated audit-line list per Render snapshot - /// (one entry per (cellId, gfxObjId) seen in BatchedByCell). The caller - /// (GameWindow EmitEnvCellProbe) prints these and tracks which pairs - /// have already been logged. - /// - public IReadOnlyList CollectCellAuditLines(HashSet<(uint cellId, ulong gfxObjId)> alreadyLogged) - { - var lines = new List(); - lock (_renderLock) - { - var snap = _activeSnapshot; - foreach (var (cellId, gfxDict) in snap.BatchedByCell) - { - foreach (var (gfxObjId, transforms) in gfxDict) - { - var key = (cellId, gfxObjId); - if (alreadyLogged.Contains(key)) continue; - alreadyLogged.Add(key); - - var rd = _meshManager.TryGetRenderData(gfxObjId); - if (rd is null) - { - lines.Add($"[a8-audit] cell=0x{cellId:X8} gfx=0x{gfxObjId:X10} instances={transforms.Count} renderData=null"); - continue; - } - int totalIdx = 0; - var cullModes = new HashSet(); - int translucent = 0; - int additive = 0; - int zeroHandle = 0; - foreach (var b in rd.Batches) - { - totalIdx += b.IndexCount; - cullModes.Add(b.CullMode); - if (b.IsTransparent) translucent++; - if (b.IsAdditive) additive++; - if (!b.TextureSlot.IsAssigned) zeroHandle++; - } - var cullList = string.Join(",", cullModes); - lines.Add( - $"[a8-audit] cell=0x{cellId:X8} gfx=0x{gfxObjId:X10} instances={transforms.Count} " + - $"isSetup={rd.IsSetup} batches={rd.Batches.Count} totalIdx={totalIdx} " + - $"cull=[{cullList}] translucent={translucent} additive={additive} zeroHandle={zeroHandle}"); - } - } - } - return lines; - } - // --------------------------------------------------------------------------- // Constructor // Campaign V slice V11: the raw-GL constructor + Initialize(Shader) two-step diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 569b551b..d4c0e500 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -215,11 +215,12 @@ public sealed record RuntimeOptions( // can be exercised on hardware that actually supports everything. VulkanForcedUnsupportedFeature: NullIfEmpty(env("ACDREAM_VULKAN_FORCE_UNSUPPORTED")), - // Campaign V slice V6h: with ACDREAM_RENDER_BACKEND=vulkan, run the - // V5/V6c bring-up harness — capability gate plus the synthetic - // verification scenes — instead of the real composition host. A - // diagnostic for "does this machine pass the Vulkan gate, and does - // the backend draw?"; ignored on OpenGL. + // Campaign V slice V6h: run the V5/V6c bring-up harness — + // capability gate plus the synthetic verification scenes — instead + // of the real composition host. A diagnostic for "does this machine + // pass the Vulkan gate, and does the backend draw?". (The former + // ACDREAM_RENDER_BACKEND=vulkan co-requisite died with the OpenGL + // backend at Campaign V; this flag alone gates the harness.) VulkanCapabilityProbe: IsExactlyOne(env("ACDREAM_VULKAN_PROBE")), // Campaign V slice V9: bound the probe harness to a frame budget so diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index 4340318b..8c455cf5 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -764,10 +764,12 @@ public static class RenderingDiagnostics /// (swap-to-swap), whole-frame GPU time, per-stage CPU attribution, /// per-frame allocation counters, reported as one [frame-prof] /// line every ~5 s. Permanent apparatus (every MP-track gate reads it) — - /// do NOT strip with session probes. The whole-frame GPU query is - /// self-disabled while ACDREAM_WB_DIAG=1 (GL forbids nested - /// TimeElapsed queries; WbDrawDispatcher owns per-pass queries under - /// that flag — the 2026-06-23 "separate flags" measurement lesson). + /// do NOT strip with session probes. This paragraph previously claimed + /// the whole-frame GPU query self-disables under ACDREAM_WB_DIAG=1; + /// Campaign V slice V11 deleted that self-disable along with the GL query + /// ring it protected, and the two flags are now independent — see + /// FrameProfiler's own class doc. Every backend reports GPU time + /// through FrameProfiler.RecordGpuSample. /// Initial state from ACDREAM_FRAME_PROF=1; runtime-toggleable /// via the DebugPanel mirror (DebugVM.FrameProf). /// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5. diff --git a/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs b/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs new file mode 100644 index 00000000..32f018dd --- /dev/null +++ b/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs @@ -0,0 +1,245 @@ +using System.Text.RegularExpressions; + +namespace AcDream.App.Tests.Diagnostics; + +/// +/// Keeps docs/launch-options.md honest: every environment variable the +/// shipped client reads must have a documented row, and every documented row +/// must name a variable something actually reads. +/// +/// +/// +/// A hand-maintained list of ~170 flags goes stale within a week. #432 is what +/// that costs: ACDREAM_AUTOMATION_ARTIFACT_DIR read like an output-path +/// setting but also constructed a per-frame diagnostics referee worth ~6 MB and +/// ~14 ms every frame, and three days of measurements were taxed before anyone +/// noticed. The same audit found CLAUDE.md still advertising +/// ACDREAM_RUN_SKILL / ACDREAM_JUMP_SKILL after their read sites +/// were deleted. Both directions of drift are failures, so both fail here. +/// +/// +/// Scope is src/ only. Test-owned variables, shader-compiler macro +/// tokens under tools/, and historical mentions in dated research +/// documents are deliberately out of scope — the doc describes what a launched +/// client reads today. +/// +/// +public sealed class LaunchOptionsDocumentationTests +{ + private const string DocRelativePath = "docs/launch-options.md"; + + /// + /// Structure rule 5 wants runtime flags behind diagnostic owner classes and + /// rule 4 wants startup configuration in RuntimeOptions. These files + /// still read the environment directly, with the exact number of distinct + /// flags each one reads today. + /// + /// + /// The counts are FROZEN, not merely the file names: promoting a stray to + /// its subsystem's owner lowers a number (update it here), and adding a new + /// direct read raises one and fails. A file that reaches zero leaves the + /// table entirely. + /// + private static readonly IReadOnlyDictionary DirectReadDebt = + new Dictionary(StringComparer.Ordinal) + { + ["src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs"] = 2, + ["src/AcDream.App/Physics/RemoteServerControlledVelocityCycle.cs"] = 1, + ["src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs"] = 1, + ["src/AcDream.App/Rendering/GameWindow.cs"] = 1, + ["src/AcDream.App/Rendering/PortalVisibilityBuilder.cs"] = 1, + ["src/AcDream.App/Rendering/Sky/SkyRenderer.cs"] = 1, + ["src/AcDream.App/Rendering/TextureCache.cs"] = 1, + ["src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs"] = 2, + ["src/AcDream.Core/Physics/PhysicsEngine.cs"] = 1, + ["src/AcDream.Core/Physics/TransitionTypes.cs"] = 3, + ["src/AcDream.Core/Vfx/PhysicsScriptRunner.cs"] = 1, + ["src/AcDream.Core/World/SkyDescLoader.cs"] = 2, + ["src/AcDream.Core.Net/GameEventWiring.cs"] = 1, + ["src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs"] = 1, + ["src/AcDream.Core.Net/Messages/UpdateMotion.cs"] = 1, + ["src/AcDream.Core.Net/WorldSession.cs"] = 3, + ["src/AcDream.Platform/ApplicationPathSet.cs"] = 3, + ["src/AcDream.Platform/BakePublicationGuardPaths.cs"] = 1, + ["src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs"] = 1, + ["src/AcDream.UI.Abstractions/Settings/QualityPreset.cs"] = 6, + }; + + /// + /// Any ACDREAM_* string literal in src/. Matching the literal + /// rather than a GetEnvironmentVariable call is deliberate: the + /// startup path reads through an injected env delegate + /// (RuntimeOptions.Parse) so a call-shaped pattern silently missed + /// ACDREAM_LIVE, ACDREAM_PAK_PATH and every other production flag. Comment + /// mentions (shader macros such as ACDREAM_SAMPLE_2D, prefix fragments) + /// carry no quotes and stay out. + /// + private static readonly Regex EnvironmentRead = new( + @"""(ACDREAM_[A-Z0-9_]+)""", + RegexOptions.Compiled); + + private static readonly Regex DocumentedRow = new( + @"^\|\s*`(ACDREAM_[A-Z0-9_]+)`", + RegexOptions.Compiled | RegexOptions.Multiline); + + [Fact] + public void EveryEnvironmentVariableTheClientReadsIsDocumented() + { + IReadOnlySet read = ReadFlags(); + IReadOnlySet documented = DocumentedFlags(); + + List undocumented = read.Except(documented).Order(StringComparer.Ordinal).ToList(); + Assert.True( + undocumented.Count == 0, + $"{DocRelativePath} is missing a row for flags the client reads: " + + string.Join(", ", undocumented) + + ". Add the row in the same commit that adds the read site."); + } + + [Fact] + public void EveryDocumentedEnvironmentVariableStillExists() + { + IReadOnlySet read = ReadFlags(); + IReadOnlySet documented = DocumentedFlags(); + + List phantom = documented.Except(read).Order(StringComparer.Ordinal).ToList(); + Assert.True( + phantom.Count == 0, + $"{DocRelativePath} documents flags nothing in src/ reads: " + + string.Join(", ", phantom) + + ". Delete the row (or move it to the retired section with its " + + "removal commit) in the same commit that deletes the read site."); + } + + [Fact] + public void DirectEnvironmentReadsOutsideOwnerClassesDoNotGrow() + { + Dictionary actual = SourceFiles() + .Where(file => !IsOwnerClass(file.RelativePath)) + .Select(file => ( + file.RelativePath, + Count: DistinctFlagsRead(File.ReadAllText(file.Path)))) + .Where(file => file.Count > 0) + .ToDictionary(file => file.RelativePath, file => file.Count, StringComparer.Ordinal); + + List problems = []; + foreach ((string path, int count) in actual.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + if (!DirectReadDebt.TryGetValue(path, out int frozen)) + { + problems.Add( + $"{path} reads {count} ACDREAM_* variable(s) directly but is " + + "not an owner class. Add the property to the subsystem's " + + "diagnostics owner (or RuntimeOptions) and read it there."); + } + else if (count > frozen) + { + problems.Add( + $"{path} grew from {frozen} to {count} direct reads. New " + + "flags belong in an owner class, not here."); + } + } + + foreach ((string path, int frozen) in DirectReadDebt.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + int count = actual.GetValueOrDefault(path, 0); + if (count < frozen) + { + problems.Add( + $"{path} is down to {count} direct read(s) from {frozen}. " + + "Lower the frozen count (or drop the entry at zero) so the " + + "debt cannot silently grow back."); + } + } + + Assert.True(problems.Count == 0, string.Join("\n", problems)); + } + + private static bool IsOwnerClass(string relativePath) + { + // The intended homes for environment reads: one static diagnostics + // class per subsystem, the typed startup options objects, the + // executables' own entry points, and the single-purpose probe/capture + // owners (a file that exists only to own one probe already satisfies + // the rule the *Diagnostics.cs suffix encodes). + string name = Path.GetFileName(relativePath); + return name.EndsWith("Diagnostics.cs", StringComparison.Ordinal) + || name.EndsWith("Options.cs", StringComparison.Ordinal) + || name.EndsWith("Probe.cs", StringComparison.Ordinal) + || name.EndsWith("Capture.cs", StringComparison.Ordinal) + || name == "Program.cs"; + } + + private static int DistinctFlagsRead(string source) + { + var flags = new HashSet(StringComparer.Ordinal); + foreach (Match match in EnvironmentRead.Matches(source)) + flags.Add(match.Groups[1].Value); + return flags.Count; + } + + private static IReadOnlySet ReadFlags() + { + var flags = new HashSet(StringComparer.Ordinal); + foreach ((string path, _) in SourceFiles()) + { + foreach (Match match in EnvironmentRead.Matches(File.ReadAllText(path))) + flags.Add(match.Groups[1].Value); + } + + Assert.True( + flags.Count > 100, + $"Only found {flags.Count} environment reads in src/; the scanner " + + "is probably broken rather than the codebase suddenly clean."); + return flags; + } + + private static IReadOnlySet DocumentedFlags() + { + string doc = Path.Combine(FindRepoRoot(), DocRelativePath.Replace('/', Path.DirectorySeparatorChar)); + Assert.True(File.Exists(doc), $"{DocRelativePath} is missing."); + + string text = File.ReadAllText(doc); + // Rows below the retired marker describe flags that are deliberately + // gone; they document history and must not resurrect the read-site + // requirement. + int retired = text.IndexOf("", StringComparison.Ordinal); + if (retired >= 0) + text = text[..retired]; + + var flags = new HashSet(StringComparer.Ordinal); + foreach (Match match in DocumentedRow.Matches(text)) + flags.Add(match.Groups[1].Value); + return flags; + } + + private static IEnumerable<(string Path, string RelativePath)> SourceFiles() + { + string root = FindRepoRoot(); + string src = Path.Combine(root, "src"); + foreach (string path in Directory.EnumerateFiles(src, "*.cs", SearchOption.AllDirectories)) + { + string relative = Path.GetRelativePath(root, path).Replace('\\', '/'); + if (relative.Contains("/bin/", StringComparison.Ordinal) + || relative.Contains("/obj/", StringComparison.Ordinal)) + { + continue; + } + + yield return (path, relative); + } + } + + private static string FindRepoRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find AcDream.slnx."); + } +} From 05bfe8d162db22722d840a8a66d768628d5eebd1 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 10:58:59 +0200 Subject: [PATCH 08/89] fix #434: delete the unreachable DebugPanel/DebugVM surface and the comments that advertised it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DebugPanel and DebugVM have not been constructible since Campaign V slice V11 removed the ImGui frontend that hosted them: nothing in src/ ever called their constructors, only a test did. Two consequences, both fixed here — 35 environment reads inside them were unreachable, and roughly forty XML doc comments across the diagnostics owners promised a runtime checkbox that no longer exists. A flag documented as runtime-toggleable when it is startup-only sends the next investigation down a path that cannot work. Deleted DebugPanel.cs (340 lines), DebugVM.cs (548) and DebugVMTests.cs (327). Corrected the surviving claims in PhysicsDiagnostics, RenderingDiagnostics, CameraDiagnostics, PhysicsEngine and GameWindow to say what is actually true: these flags are set from the environment at startup or by direct assignment. The one real dependant was CombatFeedbackSlot, whose binding target was DebugVM. It now takes a plain Action, which removes the dependency without changing behavior — and makes visible that there is no behavior: nothing binds the slot, so the combat refusals it carries ("No monster target", "Enter melee or missile combat first") have been discarded all along. Filed as #436 and pinned by a test, rather than papered over with an invented chat message; the retail text and channel need the oracle first. Deliberately untouched: F1's AcdreamToggleDebugPanel binding, which GameplayInputCommandController consumes as a documented no-op so the key does not fall through to a lower input scope; and the DebugVmRenderFactsPublisher / DevToolsRuntimeSources chain, which is still wired into production composition and deserves its own dead-code pass instead of being pulled into this one. Full hermetic suite 15,333 passed / 0 failed. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 46 +- .../Combat/LiveCombatAttackOperations.cs | 34 +- src/AcDream.App/Rendering/GameWindow.cs | 4 +- .../Physics/PhysicsDiagnostics.cs | 26 +- src/AcDream.Core/Physics/PhysicsEngine.cs | 2 +- .../Rendering/CameraDiagnostics.cs | 5 +- .../Rendering/RenderingDiagnostics.cs | 18 +- .../Panels/Debug/DebugPanel.cs | 340 ----------- .../Panels/Debug/DebugVM.cs | 548 ------------------ .../Combat/CombatFeedbackSlotTests.cs | 73 +-- .../Panels/Debug/DebugVMTests.cs | 327 ----------- 11 files changed, 129 insertions(+), 1294 deletions(-) delete mode 100644 src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs delete mode 100644 src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs delete mode 100644 tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index d9866cfb..7af44839 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -76,9 +76,53 @@ id list), so deletion order matters. --- -## #434 — The DebugPanel/DebugVM developer surface is unreachable, and ~40 doc comments still advertise it as live +## #436 — Combat refusal text ("No monster target") is silently dropped **Status:** OPEN +**Severity:** MEDIUM (missing user feedback on a common action) +**Filed:** 2026-08-24 (exposed by #434's dead-code removal) +**Component:** combat / chat presentation + +**Symptom:** `LiveCombatAttackOperations` produces two refusal messages — +`"Enter melee or missile combat first"` and `"No monster target"` — and +hands them to `CombatFeedbackSlot.Show`. Nothing in `src/` ever binds a +target to that slot, so **both messages go nowhere**. Attacking with no +target, or without a combat mode, gives the player no explanation at all. + +**How it got here:** the slot's binding target used to be the developer +`DebugVM`, which Campaign V slice V11 left unreachable (see #434). Nothing +noticed because the drop is silent — `Show` is a null-conditional invoke. +#434 converted the seam to a plain `Action` so it no longer depends +on deleted code, and pinned the current drop-on-the-floor behavior in +`CombatFeedbackSlotTests.AnUnboundSlotDropsItsMessages` so the day it gets a +real binder, that test is what changes. + +**Fix shape:** route the slot to the chat window, where retail puts this +text. Needs the retail oracle first: confirm the exact strings and their +LogTextType/channel (`claude-memory/project_chat_digest.md` has the color +and channel map) rather than inventing wording — retail's own text may +differ from these two placeholder strings. + +--- + +## #434 — CLOSED: The DebugPanel/DebugVM developer surface is unreachable, and ~40 doc comments still advertise it as live + +**Status:** CLOSED 2026-08-24. Deleted `DebugPanel.cs` (340 lines), +`DebugVM.cs` (548) and `DebugVMTests.cs` (327) — 1,215 lines. Converted the +one real dependant (`CombatFeedbackSlot`) to a delegate seam, which exposed +#436. Corrected every false "runtime-toggleable via the DebugPanel" claim in +`PhysicsDiagnostics`, `RenderingDiagnostics`, `CameraDiagnostics`, +`PhysicsEngine` and `GameWindow`. Full hermetic suite 15,333 passed / 0 +failed. **Deliberately left alone:** F1's `AcdreamToggleDebugPanel` binding, +which `GameplayInputCommandController` consumes as a documented no-op on +purpose (so the key does not fall through to a lower scope); and the +`DebugVmRenderFactsPublisher` / `DevToolsRuntimeSources` chain, which is +still wired into production composition and needs its own dead-code pass +rather than being dragged into a documentation cleanup. + +**Original report follows.** + +**Status (original):** OPEN **Severity:** LOW (no runtime defect; a documentation-truth and dead-code problem) **Filed:** 2026-08-24 (found during the launch-options audit) **Component:** UI.Abstractions / diagnostics ownership diff --git a/src/AcDream.App/Combat/LiveCombatAttackOperations.cs b/src/AcDream.App/Combat/LiveCombatAttackOperations.cs index ccac0219..96816196 100644 --- a/src/AcDream.App/Combat/LiveCombatAttackOperations.cs +++ b/src/AcDream.App/Combat/LiveCombatAttackOperations.cs @@ -66,27 +66,39 @@ internal interface ICombatFeedbackSink void Show(string message); } +/// +/// Routes combat refusal text ("No monster target") to whichever surface is +/// bound to show it. +/// +/// +/// #434: the bound target used to be the developer DebugVM, which +/// Campaign V slice V11 left unreachable — nothing has constructed it since, +/// so both messages below have been going nowhere. The binding target is now a +/// plain delegate so this seam no longer depends on that dead class, but it +/// still has no production binder: wiring it to the chat window, where retail +/// puts this text, is #436. +/// internal sealed class CombatFeedbackSlot : ICombatFeedbackSink { - private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _viewModel; + private Action? _target; - public void Bind(AcDream.UI.Abstractions.Panels.Debug.DebugVM viewModel) + public void Bind(Action target) { - ArgumentNullException.ThrowIfNull(viewModel); - if (_viewModel is not null && !ReferenceEquals(_viewModel, viewModel)) + ArgumentNullException.ThrowIfNull(target); + if (_target is not null && !ReferenceEquals(_target, target)) throw new InvalidOperationException( - "Combat feedback is already bound to a developer view model."); - _viewModel = viewModel; + "Combat feedback is already bound to a presentation target."); + _target = target; } - public void Unbind(AcDream.UI.Abstractions.Panels.Debug.DebugVM viewModel) + public void Unbind(Action target) { - ArgumentNullException.ThrowIfNull(viewModel); - if (ReferenceEquals(_viewModel, viewModel)) - _viewModel = null; + ArgumentNullException.ThrowIfNull(target); + if (ReferenceEquals(_target, target)) + _target = null; } - public void Show(string message) => _viewModel?.AddToast(message); + public void Show(string message) => _target?.Invoke(message); } internal sealed class CombatAttackOperationsSlot diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 93f8da2d..b0c4b52f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -134,8 +134,8 @@ public sealed class GameWindow : private DebugLineRenderer? _debugLines; // K-fix4 (2026-04-26): default OFF. The orange BSP / green cylinder // wireframes are noisy outdoors and confuse first-time users into - // thinking they're a rendering bug. Ctrl+F2 toggles, the DebugPanel - // → Diagnostics → "Toggle collision wires" button toggles too. + // thinking they're a rendering bug. Ctrl+F2 toggles. (The DebugPanel + // button that also toggled it is gone — #434.) private readonly AcDream.App.Rendering.WorldSceneDebugState _worldSceneDebugState = new(); diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index 7af14853..57843b0b 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -6,10 +6,12 @@ namespace AcDream.Core.Physics; /// /// L.2a slice 1 (2026-05-12) — runtime-toggleable physics probe flags. -/// Initialized from env vars at process start; flippable at runtime via -/// the DebugPanel mirror (or by direct assignment). Log call sites read -/// these statics so a checkbox toggle takes effect on the next resolve -/// without relaunching. +/// Initialized from env vars at process start; flippable at runtime by +/// direct assignment. Log call sites read these statics so a change takes +/// effect on the next resolve without relaunching. (#434: these flags used +/// to have a DebugPanel checkbox mirror. That panel has been unreachable +/// since Campaign V slice V11 removed its ImGui host, so every flag here is +/// startup-or-assignment only.) /// /// /// L.2d slice 1 (2026-05-13) adds + @@ -157,7 +159,7 @@ public static class PhysicsDiagnostics /// /// /// Initial state from ACDREAM_PROBE_BUILDING=1. Mirrorable - /// via DebugVM.ProbeBuilding when ACDREAM_DEVTOOLS=1. + /// by direct assignment (its DebugVM mirror is gone — #434). /// /// /// @@ -891,7 +893,7 @@ public static class PhysicsDiagnostics /// /// /// Toggle via env var ACDREAM_PROBE_USEABILITY_FALLBACK=1 - /// or DebugPanel checkbox. + /// by direct assignment. /// public static bool ProbeUseabilityFallbackEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_USEABILITY_FALLBACK") == "1"; @@ -915,7 +917,7 @@ public static class PhysicsDiagnostics /// the post-collision velocity disagrees with retail. /// /// Initial state from ACDREAM_DUMP_STEEP_ROOF=1. Runtime-toggleable - /// via the property setter; not yet wired to a DebugPanel checkbox (open + /// via the property setter (open /// follow-up if a debugging session calls for it). /// public static bool DumpSteepRoofEnabled { get; set; } = @@ -940,7 +942,7 @@ public static class PhysicsDiagnostics /// /// /// Initial state from ACDREAM_PROBE_INDOOR_BSP=1. - /// Runtime-toggleable via DebugPanel. + /// Runtime-toggleable by direct assignment. /// /// /// @@ -967,7 +969,7 @@ public static class PhysicsDiagnostics /// zero poly refs, candidate (b)/(d). /// /// This diagnostic fires at most once per EnvCell (cache is no-op after - /// first population). It does NOT have a DebugPanel mirror yet — this is + /// first population). This is /// a one-shot capture tool, not a persistent toggle. Promote to full /// infrastructure after the root cause is identified. /// @@ -1019,7 +1021,7 @@ public static class PhysicsDiagnostics /// /// /// Initial state from ACDREAM_PROBE_WALK_MISS=1. - /// No DebugPanel mirror — one-shot diagnostic. + /// One-shot diagnostic. /// /// /// @@ -1047,7 +1049,7 @@ public static class PhysicsDiagnostics /// /// /// Initial state from ACDREAM_PROBE_PUSH_BACK=1. - /// Runtime-toggleable via DebugVM mirror. + /// Runtime-toggleable by direct assignment. /// /// /// @@ -2034,7 +2036,7 @@ public static class PhysicsDiagnostics /// /// /// Initial state from ACDREAM_PROBE_STEP_WALK=1. One-shot - /// diagnostic; no DebugPanel mirror until the root cause is identified. + /// diagnostic. /// /// public static bool ProbeStepWalkEnabled { get; set; } = diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index d73b5979..0eaa5fee 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -2316,7 +2316,7 @@ public sealed class PhysicsEngine // L.2a slice 1 (2026-05-12): general-purpose resolver probe. // One line per call when PhysicsDiagnostics.ProbeResolveEnabled // is set (env var ACDREAM_PROBE_RESOLVE=1 at startup, or the - // DebugPanel checkbox flipped at runtime). Captures every + // property assigned at runtime). Captures every // dimension L.2 cares about: input/output position, input/output // cell, ok-vs-partial, grounded-in vs contact-out, contact-plane // status, wall normal if hit, walkable polygon valid. Zero cost diff --git a/src/AcDream.Core/Rendering/CameraDiagnostics.cs b/src/AcDream.Core/Rendering/CameraDiagnostics.cs index 0ceb5eef..02a8d448 100644 --- a/src/AcDream.Core/Rendering/CameraDiagnostics.cs +++ b/src/AcDream.Core/Rendering/CameraDiagnostics.cs @@ -6,7 +6,8 @@ namespace AcDream.Core.Rendering; /// Runtime-tunable knobs for the retail-faithful chase camera. Mirrors /// the pattern: /// static fields seeded from env vars at process start, runtime-settable -/// via property setters that the DebugPanel writes to. +/// via property setters. (#434: the DebugPanel that used to write them has +/// been unreachable since Campaign V slice V11.) /// /// /// Spec: docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md. @@ -21,7 +22,7 @@ public static class CameraDiagnostics /// AcDream.App.Rendering.ChaseCamera rigid-follow camera is. /// Initial state from ACDREAM_RETAIL_CHASE — default-on if /// unset, off only when explicitly set to "0". The legacy - /// camera stays available via the DebugPanel toggle pending the + /// camera stays available by assigning this property pending the /// follow-up deletion commit. /// public static bool UseRetailChaseCamera { get; set; } = diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index 8c455cf5..578f9246 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -8,9 +8,11 @@ namespace AcDream.Core.Rendering; /// /// 2026-05-19 — runtime-toggleable diagnostic flags for the indoor cell /// rendering pipeline. Initialized from env vars at process start; -/// flippable at runtime via the DebugPanel mirror. Log call sites read -/// these statics so a checkbox toggle takes effect on the next frame -/// without relaunching. +/// flippable at runtime by direct assignment. Log call sites read these +/// statics so a change takes effect on the next frame without relaunching. +/// (#434: these used to have a DebugPanel checkbox mirror. That panel has +/// been unreachable since Campaign V slice V11 removed its ImGui host, so +/// every flag here is startup-or-assignment only.) /// /// /// Mirrors the L.2a @@ -90,8 +92,8 @@ public static class RenderingDiagnostics /// Initial state from ACDREAM_PROBE_VIS=1. /// /// Phase U.2d (2026-05-30) repurposed this flag from the abandoned A8 - /// two-pipe stencil pass to the Phase U unified pipeline. The env var name + - /// the DebugPanel mirror (DebugVM.ProbeVisibility) are unchanged. + /// two-pipe stencil pass to the Phase U unified pipeline. The env var name + /// is unchanged (its DebugPanel mirror is gone — #434). /// /// public static bool ProbeVisibilityEnabled { get; set; } = @@ -352,8 +354,8 @@ public static class RenderingDiagnostics /// (the intensity-100 portal purples + the viewer fill off; statics stay); /// 3 = raw vLit visualization in the fragment shader (texture ignored). /// Discriminates lighting-driven stripes (gone at 1/2, visible in the field - /// at 3) from texture/per-pixel machinery (survive 1). Settable for a - /// future DebugPanel mirror. + /// at 3) from texture/per-pixel machinery (survive 1). Settable at + /// runtime by direct assignment. /// public static int LightDebugMode { get; set; } = int.TryParse( @@ -771,7 +773,7 @@ public static class RenderingDiagnostics /// FrameProfiler's own class doc. Every backend reports GPU time /// through FrameProfiler.RecordGpuSample. /// Initial state from ACDREAM_FRAME_PROF=1; runtime-toggleable - /// via the DebugPanel mirror (DebugVM.FrameProf). + /// by direct assignment (its DebugPanel mirror is gone — #434). /// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5. /// public static bool FrameProfEnabled { get; set; } = diff --git a/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs b/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs deleted file mode 100644 index 01292581..00000000 --- a/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs +++ /dev/null @@ -1,340 +0,0 @@ -using System.Numerics; - -namespace AcDream.UI.Abstractions.Panels.Debug; - -/// -/// The Phase I.2 debug panel — single ImGui window with collapsing-header -/// sections that replace the old custom DebugOverlay's six floating -/// panels (Info / Stats / Help / Compass / Chat / Event) plus the toast -/// surface. Reads through so values are always live. -/// -/// -/// Layout: Player Info, Performance, Compass, Help, Combat events, Recent -/// toasts, Diagnostics. Each section is a CollapsingHeader; -/// importance-ranked sections default open, niche ones default closed. -/// -/// -/// -/// Reuses the I.1 widget extensions only; never imports a backend -/// namespace. Same constraints as VitalsPanel and ChatPanel. -/// -/// -public sealed class DebugPanel : IPanel -{ - private readonly DebugVM _vm; - - public DebugPanel(DebugVM vm) - { - _vm = vm ?? throw new ArgumentNullException(nameof(vm)); - } - - /// - public string Id => "acdream.debug"; - - /// - public string Title => "Debug"; - - /// - public bool IsVisible { get; set; } = true; - - /// - /// Cheat-sheet of currently meaningful keybinds. Kept as a static - /// table because the data is stable and the panel only renders - /// labels — no behavior change to the bindings themselves. - /// - private static readonly (string Key, string Action)[] Keybinds = - { - // K-fix4 (2026-04-26): refreshed for the retail-default keymap + - // Phase K input-pipeline bindings. F1-F12 alone are retail panel - // toggles; acdream debug actions live behind Ctrl+F* to avoid - // retail conflicts. - ("Esc", "exit fly / close window"), - ("F11", "open Settings (key rebinding etc.)"), - ("Ctrl+Shift+F", "toggle free-fly camera"), - ("Ctrl+F1", "toggle this debug panel"), - ("Ctrl+F2", "toggle collision wireframes"), - ("Ctrl+F3", "console dump (pos + nearby objects)"), - ("Ctrl+F7", "cycle time-of-day override"), - ("Ctrl+F8 / F9", "mouse sensitivity slower / faster"), - ("Ctrl+F10", "cycle weather"), - ("W / X", "run forward / backward"), - ("A / D", "turn left / right"), - ("Z / C", "strafe left / right"), - ("Q", "autorun toggle"), - ("Shift", "walk modifier (default = run)"), - ("Space", "jump (hold to charge)"), - ("Y G H B", "stand / sit / crouch / lie"), - ("Hold MMB", "instant mouse-look"), - ("Hold RMB", "free orbit camera around player"), - ("Wheel", "zoom chase camera in / out"), - ("Tab", "focus chat input"), - }; - - /// - public void Render(PanelContext ctx, IPanelRenderer renderer) - { - if (!renderer.Begin(Title)) - { - renderer.End(); - return; - } - - DrawPlayerInfo(renderer); - DrawChaseCamera(renderer); - DrawPerformance(renderer); - DrawCompass(renderer); - DrawHelp(renderer); - DrawCombatEvents(renderer); - DrawRecentToasts(renderer); - DrawDiagnostics(renderer); - - renderer.End(); - } - - // ── Sections ────────────────────────────────────────────────────── - - private void DrawPlayerInfo(IPanelRenderer r) - { - if (!r.CollapsingHeader("Player Info", defaultOpen: true)) return; - - string mode = _vm.InPlayerMode ? "PLAYER" - : _vm.InFlyMode ? "FLY" - : "ORBIT"; - r.Text($"mode: {mode} cell: 0x{_vm.CellId:X8}"); - var p = _vm.PlayerPosition; - r.Text($"pos: ({p.X,7:F1}, {p.Y,7:F1}, {p.Z,7:F2})"); - r.Text($"heading: {_vm.HeadingDeg,3:F0}°"); - r.Text($"grounded: {(_vm.OnGround ? "yes" : "no ")} vZ: {_vm.VerticalVelocity,5:F2}"); - - string near = float.IsPositiveInfinity(_vm.NearestObjDist) - ? "---" - : $"{_vm.NearestObjDist,4:F1}m"; - if (_vm.Colliding) - { - r.TextColored(new Vector4(1f, 0.4f, 0.35f, 1f), - $"near: {near} {_vm.NearestObjLabel} [BLOCKED]"); - } - else - { - r.Text($"near: {near} {_vm.NearestObjLabel}"); - } - - if (_vm.InPlayerMode) - r.Text($"chase dist: {_vm.ChaseDistance,4:F1}m{(_vm.RmbOrbit ? " [RMB orbit]" : "")}"); - r.Text($"sens: {_vm.MouseSensitivity:F3}x"); - } - - private void DrawChaseCamera(IPanelRenderer r) - { - if (!r.CollapsingHeader("Chase camera", defaultOpen: true)) return; - - bool useRetail = _vm.UseRetailChaseCamera; - bool alignSlope = _vm.CameraAlignToSlope; - float tStiff = _vm.CameraTranslationStiffness; - float rStiff = _vm.CameraRotationStiffness; - float lpWindow = _vm.CameraMouseLowPassWindowSec; - float adjSpeed = _vm.CameraAdjustmentSpeed; - - if (r.Checkbox("Use retail chase camera (env: ACDREAM_RETAIL_CHASE)", ref useRetail)) - _vm.UseRetailChaseCamera = useRetail; - - if (r.Checkbox("Align to slope (env: ACDREAM_CAMERA_ALIGN_SLOPE)", ref alignSlope)) - _vm.CameraAlignToSlope = alignSlope; - - if (r.SliderFloat("Translation stiffness", ref tStiff, 0.05f, 1.0f)) - _vm.CameraTranslationStiffness = tStiff; - if (r.SliderFloat("Rotation stiffness", ref rStiff, 0.05f, 1.0f)) - _vm.CameraRotationStiffness = rStiff; - if (r.SliderFloat("Mouse low-pass window (s)", ref lpWindow, 0.0f, 0.5f)) - _vm.CameraMouseLowPassWindowSec = lpWindow; - if (r.SliderFloat("Adjustment speed (units/s)", ref adjSpeed, 10f, 80f)) - _vm.CameraAdjustmentSpeed = adjSpeed; - } - - private void DrawPerformance(IPanelRenderer r) - { - if (!r.CollapsingHeader("Performance", defaultOpen: true)) return; - - r.Text($"fps: {_vm.Fps,5:F0} frame: {_vm.FrameMs,5:F1} ms"); - r.Text($"visible LB: {_vm.LandblocksVisible,3}/{_vm.LandblocksTotal,3} radius: {_vm.StreamingRadius}"); - r.Text($"entities: {_vm.EntityCount,4} animated: {_vm.AnimatedCount,3} coll: {_vm.ShadowObjectCount}"); - r.Text($"lights: {_vm.ActiveLights}/{_vm.RegisteredLights} particles: {_vm.ParticleCount}"); - r.Text($"time: {_vm.DayFraction,5:F2} {_vm.HourName} weather: {_vm.Weather}"); - } - - private void DrawCompass(IPanelRenderer r) - { - if (!r.CollapsingHeader("Compass", defaultOpen: false)) return; - - // Phase I.2 stub — the visual strip + cardinal markers from the - // old DebugOverlay relied on raw 2D-rect primitives we don't (and - // shouldn't) expose through IPanelRenderer. The fancy compass - // strip lands in D.6 with proper world-HUD draw-list primitives. - // For now show heading degrees + compass cardinal label. - float h = NormalizeDeg(_vm.HeadingDeg); - r.Text($"heading: {h,3:F0}° cardinal: {Cardinal(h)}"); - } - - private void DrawHelp(IPanelRenderer r) - { - if (!r.CollapsingHeader("Help", defaultOpen: false)) return; - - r.BeginTable("debug.help", 2); - foreach (var (key, action) in Keybinds) - { - r.TableNextColumn(); - r.Text(key); - r.TableNextColumn(); - r.Text(action); - } - r.EndTable(); - } - - private void DrawCombatEvents(IPanelRenderer r) - { - if (!r.CollapsingHeader("Combat events", defaultOpen: true)) return; - - if (_vm.CombatEvents.Count == 0) - { - r.Text("(no recent combat)"); - return; - } - - foreach (var line in _vm.CombatEvents) - { - r.TextColored(ColorForCombat(line.Kind), line.Text); - } - } - - private void DrawRecentToasts(IPanelRenderer r) - { - if (!r.CollapsingHeader("Recent toasts", defaultOpen: false)) return; - - if (_vm.RecentToasts.Count == 0) - { - r.Text("(none)"); - return; - } - - foreach (var t in _vm.RecentToasts) - { - string ts = t.Timestamp.ToLocalTime().ToString("HH:mm:ss"); - r.TextColored(ColorForToast(t.Kind), $"[{ts}] {t.Text}"); - } - } - - private void DrawDiagnostics(IPanelRenderer r) - { - if (!r.CollapsingHeader("Diagnostics", defaultOpen: true)) return; - - bool dumpMotion = _vm.DumpMotion; - bool dumpVitals = _vm.DumpVitals; - bool dumpOpcodes = _vm.DumpOpcodes; - bool dumpSky = _vm.DumpSky; - bool probeResolve = _vm.ProbeResolve; - bool probeCell = _vm.ProbeCell; - bool probeBuilding = _vm.ProbeBuilding; - bool probeAutoWalk = _vm.ProbeAutoWalk; - - if (r.Checkbox("Dump motion (ACDREAM_DUMP_MOTION)", ref dumpMotion)) _vm.DumpMotion = dumpMotion; - if (r.Checkbox("Dump vitals (ACDREAM_DUMP_VITALS)", ref dumpVitals)) _vm.DumpVitals = dumpVitals; - if (r.Checkbox("Dump opcodes (ACDREAM_DUMP_OPCODES)", ref dumpOpcodes)) _vm.DumpOpcodes = dumpOpcodes; - if (r.Checkbox("Dump sky (ACDREAM_DUMP_SKY)", ref dumpSky)) _vm.DumpSky = dumpSky; - // L.2a slice 1 (2026-05-12): unlike the four above, these - // forward to PhysicsDiagnostics so a toggle takes effect live. - if (r.Checkbox("Probe resolve (ACDREAM_PROBE_RESOLVE)", ref probeResolve)) _vm.ProbeResolve = probeResolve; - if (r.Checkbox("Probe cell-transit (ACDREAM_PROBE_CELL)",ref probeCell)) _vm.ProbeCell = probeCell; - // L.2d slice 1 (2026-05-13): heavy per-hit BSP diagnostic for - // doorway / building shape-fidelity work. Emits multi-line - // [resolve-bldg] entries; expect log volume to spike at walls. - if (r.Checkbox("Probe BSP hits (ACDREAM_PROBE_BUILDING, slow)", - ref probeBuilding)) _vm.ProbeBuilding = probeBuilding; - // B.6 slice 1 (2026-05-14): local-player auto-walk trace for issue #63. - // Low volume — only the local player's UM/UP/Use/PickUp events emit. - if (r.Checkbox("Probe auto-walk (ACDREAM_PROBE_AUTOWALK)", - ref probeAutoWalk)) _vm.ProbeAutoWalk = probeAutoWalk; - - // MP0 (2026-07-05): permanent frame profiler toggle — not a - // throwaway investigation probe, so it lives with the other - // always-available diagnostics rather than a dated section. - bool frameProf = _vm.FrameProf; - if (r.Checkbox("Frame profiler ([frame-prof])", ref frameProf)) _vm.FrameProf = frameProf; - - // ── Indoor rendering diagnostics (2026-05-19) ─────────────── - // Pinpoint where the EnvCell rendering chain breaks for - // hypothesis-driven Phase 2 fix. Spec: - // docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md - r.Separator(); - r.Text("Indoor rendering (envCell):"); - - bool probeIndoorAll = _vm.ProbeIndoorAll; - bool probeIndoorWalk = _vm.ProbeIndoorWalk; - bool probeIndoorLookup = _vm.ProbeIndoorLookup; - bool probeIndoorUpload = _vm.ProbeIndoorUpload; - bool probeIndoorXform = _vm.ProbeIndoorXform; - bool probeIndoorCull = _vm.ProbeIndoorCull; - - if (r.Checkbox("Indoor: ALL (ACDREAM_PROBE_INDOOR_ALL)", ref probeIndoorAll)) _vm.ProbeIndoorAll = probeIndoorAll; - if (r.Checkbox("Indoor: walk (ACDREAM_PROBE_INDOOR_WALK)", ref probeIndoorWalk)) _vm.ProbeIndoorWalk = probeIndoorWalk; - if (r.Checkbox("Indoor: lookup (ACDREAM_PROBE_INDOOR_LOOKUP)", ref probeIndoorLookup)) _vm.ProbeIndoorLookup = probeIndoorLookup; - if (r.Checkbox("Indoor: upload (ACDREAM_PROBE_INDOOR_UPLOAD)", ref probeIndoorUpload)) _vm.ProbeIndoorUpload = probeIndoorUpload; - if (r.Checkbox("Indoor: xform (ACDREAM_PROBE_INDOOR_XFORM)", ref probeIndoorXform)) _vm.ProbeIndoorXform = probeIndoorXform; - if (r.Checkbox("Indoor: cull (ACDREAM_PROBE_INDOOR_CULL)", ref probeIndoorCull)) _vm.ProbeIndoorCull = probeIndoorCull; - - bool probeIndoorBsp = _vm.ProbeIndoorBsp; - if (r.Checkbox("Indoor: BSP collision (ACDREAM_PROBE_INDOOR_BSP)", ref probeIndoorBsp)) _vm.ProbeIndoorBsp = probeIndoorBsp; - - r.Spacing(); - - // Cycle / toggle actions live on the VM as Action handles; the - // host (GameWindow) populates them with the same lambdas the - // old F7/F10/F2 keybinds used. - if (r.Button("Cycle time of day")) _vm.CycleTimeOfDay?.Invoke(); - r.SameLine(); - if (r.Button("Cycle weather")) _vm.CycleWeather?.Invoke(); - r.SameLine(); - if (r.Button("Toggle collision wires")) _vm.ToggleCollisionWires?.Invoke(); - - // Phase K.2 — explicit free-fly toggle button. Mirrors the - // legacy F-key alias but is discoverable to users who haven't - // memorized the Ctrl+F* debug bindings. Action handle owned - // by GameWindow; null-safe for tests / offline. - if (r.Button("Toggle Free-Fly Mode")) _vm.ToggleFlyMode?.Invoke(); - - r.Text(_vm.DebugWireframes ? "collision wires: ON" : "collision wires: OFF"); - } - - // ── Color helpers ───────────────────────────────────────────────── - - private static Vector4 ColorForCombat(CombatEventKind kind) => kind switch - { - CombatEventKind.Info => new Vector4(1.0f, 0.9f, 0.3f, 1f), // yellow - CombatEventKind.Warn => new Vector4(1.0f, 0.5f, 0.5f, 1f), // light red - CombatEventKind.Error => new Vector4(1.0f, 0.3f, 0.3f, 1f), // deep red - _ => new Vector4(1f, 1f, 1f, 1f), - }; - - private static Vector4 ColorForToast(ToastKind kind) => kind switch - { - ToastKind.Warn => new Vector4(1.0f, 0.8f, 0.4f, 1f), - ToastKind.Error => new Vector4(1.0f, 0.4f, 0.4f, 1f), - _ => new Vector4(0.85f, 0.95f, 1.0f, 1f), - }; - - private static float NormalizeDeg(float deg) - { - deg %= 360f; - if (deg < 0) deg += 360f; - return deg; - } - - private static string Cardinal(float deg) - { - // Heading 0 = +X (east) per the old overlay. Same eight cardinal - // labels — N/E/S/W with NE/SE/SW/NW between. - // 0=E, 90=N, 180=W, 270=S (acdream's coordinate convention). - string[] dirs = { "E", "NE", "N", "NW", "W", "SW", "S", "SE" }; - int idx = (int)MathF.Round(deg / 45f) & 7; - return dirs[idx]; - } -} diff --git a/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs b/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs deleted file mode 100644 index 09330c68..00000000 --- a/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs +++ /dev/null @@ -1,548 +0,0 @@ -using System.Numerics; -using AcDream.Core.Combat; -using AcDream.Core.Physics; -using AcDream.Core.Rendering; - -namespace AcDream.UI.Abstractions.Panels.Debug; - -/// -/// Severity tag for a single combat-event line in the -/// ring. The panel reads this to pick -/// a TextColored rgba per row (yellow info / red warn / deep-red -/// error). Mirrors the same tri-tone the chat panel uses for combat -/// (Phase I.7). -/// -public enum CombatEventKind -{ - /// You dealt damage / landed a hit. Yellow. - Info, - /// An incoming hit you evaded. Red. - Warn, - /// You took damage. Deep red. - Error, -} - -/// -/// Single typed entry in the combat-events ring. -/// is captured at append time so a future panel revision can fade old -/// entries; for I.2 the panel just renders the text + rgba. -/// -public readonly record struct CombatEventLine( - DateTime Timestamp, - CombatEventKind Kind, - string Text); - -/// -/// Severity tag for a transient toast message. Mirrors -/// but lives in its own enum so the toast -/// surface can grow (e.g. an "OK" green) without dragging the combat -/// surface along. -/// -public enum ToastKind -{ - Info, - Warn, - Error, -} - -/// Single transient toast message kept in the recent-toasts ring. -public readonly record struct ToastMessage( - DateTime Timestamp, - ToastKind Kind, - string Text); - -/// -/// ViewModel for the Phase I.2 . Read-through -/// (no caching): every property forwards to a Func<T> that -/// the host (GameWindow) wires up at construction. Internal -/// state is limited to (a) the combat-event ring buffer, populated via a -/// self-subscription to 's typed events -/// (replacing the old DebugOverlay.BindCombat); (b) the toast -/// ring; (c) the diagnostic-flag bools the panel exposes as checkboxes. -/// -/// -/// Constructor explosion is intentional and acceptable here — the VM -/// lives entirely inside the AcDream.App composition root, not in any -/// plugin-facing surface. A nicer abstraction can come later if more -/// debug panels appear. -/// -/// -public sealed class DebugVM : IDisposable -{ - /// Maximum number of combat-event lines kept in the ring. - public const int MaxCombatEvents = 25; - - /// Maximum number of recent toast messages kept in the ring. - public const int MaxRecentToasts = 25; - - private readonly Func _getPlayerPosition; - private readonly Func _getPlayerHeadingDeg; - private readonly Func _getPlayerCellId; - private readonly Func _getPlayerOnGround; - private readonly Func _getInPlayerMode; - private readonly Func _getInFlyMode; - private readonly Func _getVerticalVelocity; - private readonly Func _getEntityCount; - private readonly Func _getAnimatedCount; - private readonly Func _getLandblocksVisible; - private readonly Func _getLandblocksTotal; - private readonly Func _getShadowObjectCount; - private readonly Func _getNearestObjDist; - private readonly Func _getNearestObjLabel; - private readonly Func _getColliding; - private readonly Func _getDebugWireframes; - private readonly Func _getStreamingRadius; - private readonly Func _getMouseSensitivity; - private readonly Func _getChaseDistance; - private readonly Func _getRmbOrbit; - private readonly Func _getHourName; - private readonly Func _getDayFraction; - private readonly Func _getWeather; - private readonly Func _getActiveLights; - private readonly Func _getRegisteredLights; - private readonly Func _getParticleCount; - private readonly Func _getFps; - private readonly Func _getFrameMs; - private readonly CombatState _combat; - private bool _disposed; - - private readonly Queue _combatEvents = new(); - private readonly Queue _toasts = new(); - - /// - /// Build a VM bound to live data sources. Every Func is read - /// per-frame by the panel — pass closures that resolve to the - /// authoritative source on each call so the panel always sees fresh - /// state. - /// - public DebugVM( - Func getPlayerPosition, - Func getPlayerHeadingDeg, - Func getPlayerCellId, - Func getPlayerOnGround, - Func getInPlayerMode, - Func getInFlyMode, - Func getVerticalVelocity, - Func getEntityCount, - Func getAnimatedCount, - Func getLandblocksVisible, - Func getLandblocksTotal, - Func getShadowObjectCount, - Func getNearestObjDist, - Func getNearestObjLabel, - Func getColliding, - Func getDebugWireframes, - Func getStreamingRadius, - Func getMouseSensitivity, - Func getChaseDistance, - Func getRmbOrbit, - Func getHourName, - Func getDayFraction, - Func getWeather, - Func getActiveLights, - Func getRegisteredLights, - Func getParticleCount, - Func getFps, - Func getFrameMs, - CombatState combat) - { - _combat = combat ?? throw new ArgumentNullException(nameof(combat)); - _getPlayerPosition = getPlayerPosition ?? throw new ArgumentNullException(nameof(getPlayerPosition)); - _getPlayerHeadingDeg = getPlayerHeadingDeg ?? throw new ArgumentNullException(nameof(getPlayerHeadingDeg)); - _getPlayerCellId = getPlayerCellId ?? throw new ArgumentNullException(nameof(getPlayerCellId)); - _getPlayerOnGround = getPlayerOnGround ?? throw new ArgumentNullException(nameof(getPlayerOnGround)); - _getInPlayerMode = getInPlayerMode ?? throw new ArgumentNullException(nameof(getInPlayerMode)); - _getInFlyMode = getInFlyMode ?? throw new ArgumentNullException(nameof(getInFlyMode)); - _getVerticalVelocity = getVerticalVelocity ?? throw new ArgumentNullException(nameof(getVerticalVelocity)); - _getEntityCount = getEntityCount ?? throw new ArgumentNullException(nameof(getEntityCount)); - _getAnimatedCount = getAnimatedCount ?? throw new ArgumentNullException(nameof(getAnimatedCount)); - _getLandblocksVisible = getLandblocksVisible ?? throw new ArgumentNullException(nameof(getLandblocksVisible)); - _getLandblocksTotal = getLandblocksTotal ?? throw new ArgumentNullException(nameof(getLandblocksTotal)); - _getShadowObjectCount = getShadowObjectCount ?? throw new ArgumentNullException(nameof(getShadowObjectCount)); - _getNearestObjDist = getNearestObjDist ?? throw new ArgumentNullException(nameof(getNearestObjDist)); - _getNearestObjLabel = getNearestObjLabel ?? throw new ArgumentNullException(nameof(getNearestObjLabel)); - _getColliding = getColliding ?? throw new ArgumentNullException(nameof(getColliding)); - _getDebugWireframes = getDebugWireframes ?? throw new ArgumentNullException(nameof(getDebugWireframes)); - _getStreamingRadius = getStreamingRadius ?? throw new ArgumentNullException(nameof(getStreamingRadius)); - _getMouseSensitivity = getMouseSensitivity ?? throw new ArgumentNullException(nameof(getMouseSensitivity)); - _getChaseDistance = getChaseDistance ?? throw new ArgumentNullException(nameof(getChaseDistance)); - _getRmbOrbit = getRmbOrbit ?? throw new ArgumentNullException(nameof(getRmbOrbit)); - _getHourName = getHourName ?? throw new ArgumentNullException(nameof(getHourName)); - _getDayFraction = getDayFraction ?? throw new ArgumentNullException(nameof(getDayFraction)); - _getWeather = getWeather ?? throw new ArgumentNullException(nameof(getWeather)); - _getActiveLights = getActiveLights ?? throw new ArgumentNullException(nameof(getActiveLights)); - _getRegisteredLights = getRegisteredLights ?? throw new ArgumentNullException(nameof(getRegisteredLights)); - _getParticleCount = getParticleCount ?? throw new ArgumentNullException(nameof(getParticleCount)); - _getFps = getFps ?? throw new ArgumentNullException(nameof(getFps)); - _getFrameMs = getFrameMs ?? throw new ArgumentNullException(nameof(getFrameMs)); - - // Self-subscribe to combat events. Each one becomes a typed entry - // in the ring; the panel renders them in TextColored. Replaces - // the old DebugOverlay.BindCombat side-channel. - _combat.DamageTaken += OnDamageTaken; - _combat.DamageDealtAccepted += OnDamageDealt; - _combat.EvadedIncoming += OnEvadedIncoming; - _combat.MissedOutgoing += OnMissedOutgoing; - _combat.AttackDone += OnAttackDone; - _combat.KillLanded += OnKillLanded; - } - - // ── Read-through value surfaces ─────────────────────────────────── - - public Vector3 PlayerPosition => _getPlayerPosition(); - public float HeadingDeg => _getPlayerHeadingDeg(); - public uint CellId => _getPlayerCellId(); - public bool OnGround => _getPlayerOnGround(); - public bool InPlayerMode => _getInPlayerMode(); - public bool InFlyMode => _getInFlyMode(); - public float VerticalVelocity => _getVerticalVelocity(); - public int EntityCount => _getEntityCount(); - public int AnimatedCount => _getAnimatedCount(); - public int LandblocksVisible => _getLandblocksVisible(); - public int LandblocksTotal => _getLandblocksTotal(); - public int ShadowObjectCount => _getShadowObjectCount(); - public float NearestObjDist => _getNearestObjDist(); - public string NearestObjLabel => _getNearestObjLabel(); - public bool Colliding => _getColliding(); - public bool DebugWireframes => _getDebugWireframes(); - public int StreamingRadius => _getStreamingRadius(); - public float MouseSensitivity => _getMouseSensitivity(); - public float ChaseDistance => _getChaseDistance(); - public bool RmbOrbit => _getRmbOrbit(); - public string HourName => _getHourName(); - public float DayFraction => _getDayFraction(); - public string Weather => _getWeather(); - public int ActiveLights => _getActiveLights(); - public int RegisteredLights => _getRegisteredLights(); - public int ParticleCount => _getParticleCount(); - public float Fps => _getFps(); - public float FrameMs => _getFrameMs(); - - // ── Diagnostic toggles (env-var-style runtime flags) ─────────────── - - /// Mirror of ACDREAM_DUMP_MOTION; flipped at runtime via the panel. - public bool DumpMotion { get; set; } - /// Mirror of ACDREAM_DUMP_VITALS. - public bool DumpVitals { get; set; } - /// Mirror of ACDREAM_DUMP_OPCODES. - public bool DumpOpcodes { get; set; } - /// Mirror of ACDREAM_DUMP_SKY. - public bool DumpSky { get; set; } - - // L.2a slice 1 (2026-05-12): unlike DumpMotion/Vitals/Opcodes/Sky - // above (which are display-only mirrors of sticky-at-startup env - // vars), these forward directly to the PhysicsDiagnostics statics, - // so checkbox toggles take effect on the next physics resolve. - /// - /// Runtime mirror of PhysicsDiagnostics.ProbeResolveEnabled - /// (env var ACDREAM_PROBE_RESOLVE). Toggling here flips the - /// resolver probe live — no relaunch required. - /// - public bool ProbeResolve - { - get => PhysicsDiagnostics.ProbeResolveEnabled; - set => PhysicsDiagnostics.ProbeResolveEnabled = value; - } - - /// - /// Runtime mirror of PhysicsDiagnostics.ProbeCellEnabled - /// (env var ACDREAM_PROBE_CELL). Toggling here flips the - /// cell-transit probe live. - /// - public bool ProbeCell - { - get => PhysicsDiagnostics.ProbeCellEnabled; - set => PhysicsDiagnostics.ProbeCellEnabled = value; - } - - /// - /// L.2d slice 1 (2026-05-13). Runtime mirror of - /// PhysicsDiagnostics.ProbeBuildingEnabled (env var - /// ACDREAM_PROBE_BUILDING). Toggling here flips the per-hit - /// [resolve-bldg] diagnostic + the registration-time - /// [entity-source] log lines. Heavy when enabled — emits one - /// multi-line entry per BSP hit per physics tick. - /// - public bool ProbeBuilding - { - get => PhysicsDiagnostics.ProbeBuildingEnabled; - set => PhysicsDiagnostics.ProbeBuildingEnabled = value; - } - - /// - /// B.6 slice 1 (2026-05-14). Runtime mirror of - /// PhysicsDiagnostics.ProbeAutoWalkEnabled (env var - /// ACDREAM_PROBE_AUTOWALK). Toggling here flips the - /// [autowalk-out] / [autowalk-mt] / [autowalk-up] - /// trace used to characterize ACE's behavior during a server- - /// initiated auto-walk (issue #63). Low volume when off — only the - /// local player's events are filtered through the probe. - /// - public bool ProbeAutoWalk - { - get => PhysicsDiagnostics.ProbeAutoWalkEnabled; - set => PhysicsDiagnostics.ProbeAutoWalkEnabled = value; - } - - /// - /// Runtime mirror of RenderingDiagnostics.FrameProfEnabled - /// (env var ACDREAM_FRAME_PROF). Toggling here starts/stops the - /// [frame-prof] 5-second report live — no relaunch required. - /// - public bool FrameProf - { - get => RenderingDiagnostics.FrameProfEnabled; - set => RenderingDiagnostics.FrameProfEnabled = value; - } - - // ── Indoor rendering diagnostics (2026-05-19) ─────────────────── - // Mirror RenderingDiagnostics statics so DebugPanel checkbox toggles - // take effect on the next render frame without relaunching. - - /// - /// Runtime mirror of RenderingDiagnostics.ProbeIndoorWalkEnabled - /// (env var ACDREAM_PROBE_INDOOR_WALK). - /// - public bool ProbeIndoorWalk - { - get => RenderingDiagnostics.ProbeIndoorWalkEnabled; - set => RenderingDiagnostics.ProbeIndoorWalkEnabled = value; - } - - /// - /// Runtime mirror of RenderingDiagnostics.ProbeIndoorLookupEnabled - /// (env var ACDREAM_PROBE_INDOOR_LOOKUP). - /// - public bool ProbeIndoorLookup - { - get => RenderingDiagnostics.ProbeIndoorLookupEnabled; - set => RenderingDiagnostics.ProbeIndoorLookupEnabled = value; - } - - /// - /// Runtime mirror of RenderingDiagnostics.ProbeIndoorUploadEnabled - /// (env var ACDREAM_PROBE_INDOOR_UPLOAD). - /// - public bool ProbeIndoorUpload - { - get => RenderingDiagnostics.ProbeIndoorUploadEnabled; - set => RenderingDiagnostics.ProbeIndoorUploadEnabled = value; - } - - /// - /// Runtime mirror of RenderingDiagnostics.ProbeIndoorXformEnabled - /// (env var ACDREAM_PROBE_INDOOR_XFORM). - /// - public bool ProbeIndoorXform - { - get => RenderingDiagnostics.ProbeIndoorXformEnabled; - set => RenderingDiagnostics.ProbeIndoorXformEnabled = value; - } - - /// - /// Runtime mirror of RenderingDiagnostics.ProbeIndoorCullEnabled - /// (env var ACDREAM_PROBE_INDOOR_CULL). - /// - public bool ProbeIndoorCull - { - get => RenderingDiagnostics.ProbeIndoorCullEnabled; - set => RenderingDiagnostics.ProbeIndoorCullEnabled = value; - } - - /// - /// Phase A8 (2026-05-25). Runtime mirror of - /// RenderingDiagnostics.ProbeVisibilityEnabled - /// (env var ACDREAM_PROBE_VIS). - /// - public bool ProbeVisibility - { - get => RenderingDiagnostics.ProbeVisibilityEnabled; - set => RenderingDiagnostics.ProbeVisibilityEnabled = value; - } - - /// - /// Indoor walking Phase 1 (2026-05-19). Runtime mirror of - /// PhysicsDiagnostics.ProbeIndoorBspEnabled (env var - /// ACDREAM_PROBE_INDOOR_BSP). Toggling here flips the - /// [indoor-bsp] probe live — no relaunch required. - /// Physics-side companion to the five render-side - /// ProbeIndoor* mirrors directly above. - /// - public bool ProbeIndoorBsp - { - get => PhysicsDiagnostics.ProbeIndoorBspEnabled; - set => PhysicsDiagnostics.ProbeIndoorBspEnabled = value; - } - - /// - /// Phase A6.P1 cdb probe spike (2026-05-21). Runtime mirror of - /// (env var - /// ACDREAM_PROBE_PUSH_BACK). Toggling here flips the three - /// [push-back] emission sites live — no relaunch required. - /// - public bool ProbePushBack - { - get => PhysicsDiagnostics.ProbePushBackEnabled; - set => PhysicsDiagnostics.ProbePushBackEnabled = value; - } - - /// - /// Runtime mirror of RenderingDiagnostics.IndoorAll — toggles all - /// five indoor probes together. No dedicated env var; set any individual - /// probe env var or use ACDREAM_PROBE_INDOOR_ALL to initialize - /// all five flags on at startup. - /// - public bool ProbeIndoorAll - { - get => RenderingDiagnostics.IndoorAll; - set => RenderingDiagnostics.IndoorAll = value; - } - - // ── Chase camera tunables (forward to CameraDiagnostics) ────────── - - /// Runtime mirror of . - public bool UseRetailChaseCamera - { - get => CameraDiagnostics.UseRetailChaseCamera; - set => CameraDiagnostics.UseRetailChaseCamera = value; - } - - /// Runtime mirror of . - public bool CameraAlignToSlope - { - get => CameraDiagnostics.AlignToSlope; - set => CameraDiagnostics.AlignToSlope = value; - } - - /// Runtime mirror of . - public float CameraTranslationStiffness - { - get => CameraDiagnostics.TranslationStiffness; - set => CameraDiagnostics.TranslationStiffness = value; - } - - /// Runtime mirror of . - public float CameraRotationStiffness - { - get => CameraDiagnostics.RotationStiffness; - set => CameraDiagnostics.RotationStiffness = value; - } - - /// Runtime mirror of . - public float CameraMouseLowPassWindowSec - { - get => CameraDiagnostics.MouseLowPassWindowSec; - set => CameraDiagnostics.MouseLowPassWindowSec = value; - } - - /// Runtime mirror of . - public float CameraAdjustmentSpeed - { - get => CameraDiagnostics.CameraAdjustmentSpeed; - set => CameraDiagnostics.CameraAdjustmentSpeed = value; - } - - // ── Action hooks invoked by panel buttons ────────────────────────── - - /// - /// Cycle the time-of-day debug override (matches the old F7 - /// behavior — none → midnight → dawn → noon → dusk → none). Wired - /// by GameWindow; null when no host is available (tests). - /// - public Action? CycleTimeOfDay { get; set; } - - /// - /// Cycle the weather-kind debug override (matches the old F10 - /// behavior — clear → overcast → rain → snow → storm). - /// - public Action? CycleWeather { get; set; } - - /// - /// Toggle the collision-wires debug renderer. Same effect as the - /// old F2 keybind, which we keep as a hotkey alias. - /// - public Action? ToggleCollisionWires { get; set; } - - /// - /// Phase K.2 — toggle the free-fly camera. Lets a user opt out of - /// the auto-entered chase camera (e.g. to inspect a remote part of - /// the world without the player following) without needing to find - /// the Ctrl+F* debug binding. Wired by GameWindow to the - /// same routine the legacy F-key fly toggle invokes. - /// - public Action? ToggleFlyMode { get; set; } - - // ── Combat event ring + toast ring ───────────────────────────────── - - /// - /// Snapshot view of the combat-event ring. Oldest-first; the panel - /// can iterate and render each line through TextColored - /// based on . - /// - public IReadOnlyCollection CombatEvents => _combatEvents; - - /// Snapshot view of the recent-toasts ring (oldest-first). - public IReadOnlyCollection RecentToasts => _toasts; - - /// - /// Append a toast message to the ring. Cap at - /// ; oldest entries drop. The panel's - /// "Recent toasts" section reads this; no on-screen flash for I.2. - /// - public void AddToast(string text, ToastKind kind = ToastKind.Info) - { - if (string.IsNullOrEmpty(text)) return; - _toasts.Enqueue(new ToastMessage(DateTime.UtcNow, kind, text)); - while (_toasts.Count > MaxRecentToasts) - _toasts.Dequeue(); - } - - private void Push(CombatEventKind kind, string text) - { - _combatEvents.Enqueue(new CombatEventLine(DateTime.UtcNow, kind, text)); - while (_combatEvents.Count > MaxCombatEvents) - _combatEvents.Dequeue(); - } - - public void Dispose() - { - if (_disposed) - return; - - _combat.DamageTaken -= OnDamageTaken; - _combat.DamageDealtAccepted -= OnDamageDealt; - _combat.EvadedIncoming -= OnEvadedIncoming; - _combat.MissedOutgoing -= OnMissedOutgoing; - _combat.AttackDone -= OnAttackDone; - _combat.KillLanded -= OnKillLanded; - _disposed = true; - } - - private void OnDamageTaken(CombatState.DamageIncoming damage) => - Push( - CombatEventKind.Error, - $"<< {damage.AttackerName} hit you for {damage.Damage}" + - (damage.Critical ? " CRIT!" : string.Empty)); - - private void OnDamageDealt(CombatState.DamageDealt damage) => - Push( - CombatEventKind.Info, - $">> you hit {damage.DefenderName} for {damage.Damage}"); - - private void OnEvadedIncoming(string attacker) => - Push(CombatEventKind.Warn, $"<< {attacker}'s attack missed you"); - - private void OnMissedOutgoing(string defender) => - Push(CombatEventKind.Info, $">> your attack missed {defender}"); - - private void OnAttackDone(uint _, uint weenieError) - { - if (weenieError != 0) - Push( - CombatEventKind.Error, - $"!! attack failed (error 0x{weenieError:X})"); - } - - private void OnKillLanded(string victim, uint _) => - Push(CombatEventKind.Info, $"** you killed {victim}"); -} diff --git a/tests/AcDream.App.Tests/Combat/CombatFeedbackSlotTests.cs b/tests/AcDream.App.Tests/Combat/CombatFeedbackSlotTests.cs index 4fe8a669..2f2e6d9e 100644 --- a/tests/AcDream.App.Tests/Combat/CombatFeedbackSlotTests.cs +++ b/tests/AcDream.App.Tests/Combat/CombatFeedbackSlotTests.cs @@ -1,7 +1,4 @@ -using System.Numerics; using AcDream.App.Combat; -using AcDream.Core.Combat; -using AcDream.UI.Abstractions.Panels.Debug; namespace AcDream.App.Tests.Combat; @@ -11,49 +8,41 @@ public sealed class CombatFeedbackSlotTests public void ExpectedOwnerUnbindCannotClearReplacement() { var slot = new CombatFeedbackSlot(); - using DebugVM first = CreateViewModel(); - using DebugVM second = CreateViewModel(); + List first = []; + List second = []; + Action firstTarget = first.Add; + Action secondTarget = second.Add; - slot.Bind(first); - slot.Unbind(second); + slot.Bind(firstTarget); + slot.Unbind(secondTarget); slot.Show("first"); - Assert.Single(first.RecentToasts); - Assert.Empty(second.RecentToasts); + Assert.Equal(["first"], first); + Assert.Empty(second); - slot.Unbind(first); - slot.Bind(second); + slot.Unbind(firstTarget); + slot.Bind(secondTarget); slot.Show("second"); - Assert.Single(second.RecentToasts); + Assert.Equal(["second"], second); } - private static DebugVM CreateViewModel() => new( - static () => Vector3.Zero, - static () => 0, - static () => 0, - static () => false, - static () => false, - static () => false, - static () => 0, - static () => 0, - static () => 0, - static () => 0, - static () => 0, - static () => 0, - static () => float.PositiveInfinity, - static () => "-", - static () => false, - static () => false, - static () => 0, - static () => 1, - static () => 0, - static () => false, - static () => "0", - static () => 0, - static () => "Clear", - static () => 0, - static () => 0, - static () => 0, - static () => 60, - static () => 16.7f, - new CombatState()); + [Fact] + public void RebindingADifferentTargetWhileBoundIsRejected() + { + var slot = new CombatFeedbackSlot(); + slot.Bind(_ => { }); + + Assert.Throws(() => slot.Bind(_ => { })); + } + + [Fact] + public void AnUnboundSlotDropsItsMessages() + { + // #434/#436: this is the shipped behavior, not an aspiration — + // nothing binds the slot in production, so combat refusal text + // ("No monster target") is discarded. Pinned so the day it gets a + // real binder, this test is the one that has to change. + var slot = new CombatFeedbackSlot(); + + slot.Show("dropped"); + } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs deleted file mode 100644 index 3028cfd0..00000000 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs +++ /dev/null @@ -1,327 +0,0 @@ -using System.Numerics; -using AcDream.Core.Combat; -using AcDream.Core.Physics; -using AcDream.UI.Abstractions.Panels.Debug; - -namespace AcDream.UI.Abstractions.Tests.Panels.Debug; - -/// -/// Tests for the Phase I.2 — read-through ViewModel -/// for the migrated debug panel. Verifies the combat-event subscription -/// (replacing the old DebugOverlay.BindCombat), the toast ring -/// cap, and that the diagnostic-flag bools round-trip without affecting -/// the rest of the VM. -/// -public sealed class DebugVMTests -{ - [Fact] - public void DisposeDetachesEveryCombatSubscriptionExactlyOnce() - { - var combat = new CombatState(); - DebugVM vm = NewVm(combat); - combat.OnKillerNotification("first", 1); - Assert.Single(vm.CombatEvents); - - vm.Dispose(); - vm.Dispose(); - combat.OnKillerNotification("second", 2); - combat.OnAttackDone(3, 4); - - Assert.Single(vm.CombatEvents); - } - - /// - /// Build a minimal with safe defaults for every - /// constructor source. Tests that don't care about a particular source - /// just leave it stubbed out. - /// - private static DebugVM NewVm(CombatState? combat = null) - { - combat ??= new CombatState(); - return new DebugVM( - getPlayerPosition: () => Vector3.Zero, - getPlayerHeadingDeg: () => 0f, - getPlayerCellId: () => 0u, - getPlayerOnGround: () => true, - getInPlayerMode: () => false, - getInFlyMode: () => false, - getVerticalVelocity: () => 0f, - getEntityCount: () => 0, - getAnimatedCount: () => 0, - getLandblocksVisible: () => 0, - getLandblocksTotal: () => 0, - getShadowObjectCount: () => 0, - getNearestObjDist: () => float.PositiveInfinity, - getNearestObjLabel: () => "-", - getColliding: () => false, - getDebugWireframes: () => false, - getStreamingRadius: () => 2, - getMouseSensitivity: () => 1f, - getChaseDistance: () => 0f, - getRmbOrbit: () => false, - getHourName: () => "Dawnsong", - getDayFraction: () => 0.25f, - getWeather: () => "Clear", - getActiveLights: () => 0, - getRegisteredLights: () => 0, - getParticleCount: () => 0, - getFps: () => 60f, - getFrameMs: () => 16.7f, - combat: combat); - } - - [Fact] - public void Constructor_ThrowsOnNullCombat() - { - Assert.Throws(() => new DebugVM( - getPlayerPosition: () => Vector3.Zero, - getPlayerHeadingDeg: () => 0f, - getPlayerCellId: () => 0u, - getPlayerOnGround: () => true, - getInPlayerMode: () => false, - getInFlyMode: () => false, - getVerticalVelocity: () => 0f, - getEntityCount: () => 0, - getAnimatedCount: () => 0, - getLandblocksVisible: () => 0, - getLandblocksTotal: () => 0, - getShadowObjectCount: () => 0, - getNearestObjDist: () => 0f, - getNearestObjLabel: () => "-", - getColliding: () => false, - getDebugWireframes: () => false, - getStreamingRadius: () => 0, - getMouseSensitivity: () => 1f, - getChaseDistance: () => 0f, - getRmbOrbit: () => false, - getHourName: () => "", - getDayFraction: () => 0f, - getWeather: () => "", - getActiveLights: () => 0, - getRegisteredLights: () => 0, - getParticleCount: () => 0, - getFps: () => 0f, - getFrameMs: () => 0f, - combat: null!)); - } - - [Fact] - public void ReadThrough_PullsLiveValuesPerAccess_NoCache() - { - // The VM does NOT cache; every property read goes through the - // backing Func. Mutating an external box between two reads - // must surface immediately. - int counter = 0; - var vm = new DebugVM( - getPlayerPosition: () => Vector3.Zero, - getPlayerHeadingDeg: () => 0f, - getPlayerCellId: () => 0u, - getPlayerOnGround: () => true, - getInPlayerMode: () => false, - getInFlyMode: () => false, - getVerticalVelocity: () => 0f, - getEntityCount: () => ++counter, - getAnimatedCount: () => 0, - getLandblocksVisible: () => 0, - getLandblocksTotal: () => 0, - getShadowObjectCount: () => 0, - getNearestObjDist: () => 0f, - getNearestObjLabel: () => "-", - getColliding: () => false, - getDebugWireframes: () => false, - getStreamingRadius: () => 0, - getMouseSensitivity: () => 1f, - getChaseDistance: () => 0f, - getRmbOrbit: () => false, - getHourName: () => "", - getDayFraction: () => 0f, - getWeather: () => "", - getActiveLights: () => 0, - getRegisteredLights: () => 0, - getParticleCount: () => 0, - getFps: () => 0f, - getFrameMs: () => 0f, - combat: new CombatState()); - - Assert.Equal(1, vm.EntityCount); - Assert.Equal(2, vm.EntityCount); - Assert.Equal(3, vm.EntityCount); - } - - [Fact] - public void DamageTaken_AppendsErrorEvent() - { - var combat = new CombatState(); - var vm = NewVm(combat); - - combat.OnVictimNotification( - attackerName: "Drudge", attackerGuid: 0x10u, - damageType: 0u, damage: 12u, hitQuadrant: 0u, - critical: 0u, attackType: 0u); - - var events = vm.CombatEvents.ToList(); - Assert.Single(events); - Assert.Equal(CombatEventKind.Error, events[0].Kind); - Assert.Contains("Drudge", events[0].Text); - Assert.Contains("12", events[0].Text); - } - - [Fact] - public void DamageDealt_AppendsInfoEvent() - { - var combat = new CombatState(); - var vm = NewVm(combat); - - combat.OnAttackerNotification( - defenderName: "Drudge", damageType: 0u, - damage: 8u, damagePercent: 0.1f); - - var events = vm.CombatEvents.ToList(); - Assert.Single(events); - Assert.Equal(CombatEventKind.Info, events[0].Kind); - Assert.Contains("Drudge", events[0].Text); - } - - [Fact] - public void EvadedIncoming_AppendsWarnEvent() - { - var combat = new CombatState(); - var vm = NewVm(combat); - - combat.OnEvasionDefenderNotification("Mosswart"); - - var events = vm.CombatEvents.ToList(); - Assert.Single(events); - Assert.Equal(CombatEventKind.Warn, events[0].Kind); - Assert.Contains("Mosswart", events[0].Text); - } - - [Fact] - public void CombatEventRing_CapsAtMax_DropsOldest() - { - var combat = new CombatState(); - var vm = NewVm(combat); - - // Pump well past the cap (25). The oldest entries must drop out. - for (int i = 0; i < 40; i++) - { - combat.OnAttackerNotification( - defenderName: $"Foe{i}", damageType: 0u, - damage: (uint)i, damagePercent: 0.1f); - } - - var events = vm.CombatEvents.ToList(); - Assert.Equal(DebugVM.MaxCombatEvents, events.Count); - // The newest entry must still be present. - Assert.Contains(events, e => e.Text.Contains("Foe39")); - // The oldest must have dropped. - Assert.DoesNotContain(events, e => e.Text.Contains("Foe0,") || e.Text == "Foe0"); - Assert.DoesNotContain(events, e => e.Text.Contains("Foe5")); - } - - [Fact] - public void Toast_RingCapsAtMaxRecent() - { - var vm = NewVm(); - - for (int i = 0; i < 30; i++) - vm.AddToast($"toast-{i}"); - - var toasts = vm.RecentToasts.ToList(); - Assert.Equal(DebugVM.MaxRecentToasts, toasts.Count); - Assert.Contains(toasts, t => t.Text == "toast-29"); - Assert.DoesNotContain(toasts, t => t.Text == "toast-0"); - } - - [Fact] - public void Toast_PreservesKind() - { - var vm = NewVm(); - vm.AddToast("warning!", ToastKind.Warn); - vm.AddToast("error!", ToastKind.Error); - - var toasts = vm.RecentToasts.ToList(); - Assert.Equal(2, toasts.Count); - Assert.Contains(toasts, t => t.Text == "warning!" && t.Kind == ToastKind.Warn); - Assert.Contains(toasts, t => t.Text == "error!" && t.Kind == ToastKind.Error); - } - - [Fact] - public void DiagnosticFlags_DefaultFalse_RoundTripIndependently() - { - var vm = NewVm(); - Assert.False(vm.DumpMotion); - Assert.False(vm.DumpVitals); - Assert.False(vm.DumpOpcodes); - Assert.False(vm.DumpSky); - - vm.DumpMotion = true; - Assert.True(vm.DumpMotion); - Assert.False(vm.DumpVitals); - Assert.False(vm.DumpOpcodes); - Assert.False(vm.DumpSky); - - vm.DumpSky = true; - vm.DumpMotion = false; - Assert.False(vm.DumpMotion); - Assert.True(vm.DumpSky); - } - - [Fact] - public void ToggleFlags_DoNotAffectCombatRing() - { - var combat = new CombatState(); - var vm = NewVm(combat); - - combat.OnAttackerNotification("X", 0u, 1u, 0.1f); - Assert.Single(vm.CombatEvents); - - vm.DumpMotion = true; - vm.DumpVitals = true; - Assert.Single(vm.CombatEvents); - } - - [Fact] - public void ActionHooks_InvokeSuppliedDelegates() - { - // The panel needs to invoke "cycle time", "cycle weather", "toggle - // collision wires" actions when the corresponding Button is - // clicked. The VM exposes these as Action; the panel calls them. - int timeHits = 0, weatherHits = 0, wireHits = 0; - var vm = NewVm(); - vm.CycleTimeOfDay = () => timeHits++; - vm.CycleWeather = () => weatherHits++; - vm.ToggleCollisionWires = () => wireHits++; - - vm.CycleTimeOfDay?.Invoke(); - vm.CycleTimeOfDay?.Invoke(); - vm.CycleWeather?.Invoke(); - vm.ToggleCollisionWires?.Invoke(); - - Assert.Equal(2, timeHits); - Assert.Equal(1, weatherHits); - Assert.Equal(1, wireHits); - } - - [Fact] - public void ProbeIndoorBsp_ForwardsToPhysicsDiagnostics() - { - var originalEnabled = PhysicsDiagnostics.ProbeIndoorBspEnabled; - try - { - var vm = NewVm(); - - vm.ProbeIndoorBsp = true; - Assert.True(PhysicsDiagnostics.ProbeIndoorBspEnabled); - Assert.True(vm.ProbeIndoorBsp); - - vm.ProbeIndoorBsp = false; - Assert.False(PhysicsDiagnostics.ProbeIndoorBspEnabled); - Assert.False(vm.ProbeIndoorBsp); - } - finally - { - PhysicsDiagnostics.ProbeIndoorBspEnabled = originalEnabled; - } - } -} From 0c5057c9ffaa1a2a1867cba3b2d2c5d7e67c47e3 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 11:41:20 +0200 Subject: [PATCH 09/89] fix #435 (part 1): delete 17 probes that outlived their closed investigations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of these was temporary apparatus added to chase one bug, and each was supposed to be deleted in the commit that fixed it. Fourteen closed issues later they were still here: #337's support/wire-mesh trio, #171's sticky timeline, #119's viewer and entity dumps, #113's phantom probe, and a dozen more. 3,493 lines removed; the client now reads 144 environment variables instead of 161, and 47 temporary probes remain instead of 64. This is not only tidying. Every probe leaves a branch on its hot path when unset, several re-read the environment per call rather than caching, and the volume buries the diagnostics that are actually load-bearing. It is also a headless correctness matter: HeadlessStaticStateAudit reflects over PhysicsDiagnostics' flags to refuse a multi-session host when any is set, and cannot see probes that live outside that owner. Four files went entirely — WalkMissDiagnostic.cs, CollisionMeshWireframe.cs and two test files whose only subject was a deleted probe. TransitionTypes.SetContactPlane also sheds its CallerMemberName / CallerLineNumber parameters, which existed solely for #337's cpSrc= attribution and carried the instruction to strip them with the probe family; no call site passed them, so no behavior changes. F2's collision overlay survives and reverts to its proxy-cylinder form, which is what removing the ACDREAM_WIRE_MESH upgrade means. LaunchOptionsDocumentationTests earned its keep here: it refused the deletion until docs/launch-options.md moved the 17 rows into Retired and the frozen direct-read counts came down (PhysicsEngine.cs to zero, TransitionTypes.cs 3 to 2). The documentation could not drift during a cleanup this wide. The 14 probes that name no owning issue are deliberately NOT deleted. Nothing records when they became safe to remove, and guessing is how a future investigation loses apparatus it needed; #435 stays open for their attribution. Full hermetic suite 15,321 passed / 0 failed. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 30 +- docs/launch-options.md | 51 +- src/AcDream.App/Input/PlayerModeController.cs | 2 - .../LiveEntityMotionRuntimeController.cs | 28 - .../LiveEntityNetworkUpdateController.cs | 87 -- .../Rendering/CollisionMeshWireframe.cs | 372 ------ .../Rendering/RetailPViewPassExecutor.cs | 38 - .../Rendering/RetailPViewRenderer.cs | 29 - .../Rendering/Wb/EnvCellRenderer.cs | 42 - .../Rendering/Wb/WbDrawDispatcher.cs | 137 -- src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs | 34 - .../Rendering/WorldRenderDiagnostics.cs | 195 --- .../Rendering/WorldRenderFrameBuilder.cs | 11 - .../WorldSceneDiagnosticsController.cs | 60 - .../Streaming/LandblockBuildFactory.cs | 44 - .../Physics/Motion/MotionTableDispatchSink.cs | 6 - .../Physics/Motion/StickyManager.cs | 20 - src/AcDream.Core/Physics/PhysicsDataCache.cs | 17 - .../Physics/PhysicsDiagnostics.cs | 1174 ----------------- src/AcDream.Core/Physics/PhysicsEngine.cs | 129 -- src/AcDream.Core/Physics/TransitionTypes.cs | 245 +--- .../Physics/WalkMissDiagnostic.cs | 173 --- .../Rendering/RenderingDiagnostics.cs | 258 ---- ...ntimeLocalPlayerPhysicsPublicationState.cs | 27 - .../Physics/RuntimeRemotePhysicsUpdater.cs | 44 - .../RuntimeSetPositionMoverPreparation.cs | 9 - .../LaunchOptionsDocumentationTests.cs | 3 +- .../Rendering/RetailPViewPassExecutorTests.cs | 14 - .../Rendering/WorldRenderDiagnosticsTests.cs | 17 - .../Rendering/WorldRenderFrameBuilderTests.cs | 2 - .../Physics/SupportProbeClassifierTests.cs | 120 -- .../Physics/TransitFailProbeTests.cs | 8 +- .../Physics/WalkMissDiagnosticTests.cs | 119 -- .../Rendering/RenderingDiagnosticsTests.cs | 7 - 34 files changed, 57 insertions(+), 3495 deletions(-) delete mode 100644 src/AcDream.App/Rendering/CollisionMeshWireframe.cs delete mode 100644 src/AcDream.Core/Physics/WalkMissDiagnostic.cs delete mode 100644 tests/AcDream.Core.Tests/Physics/SupportProbeClassifierTests.cs delete mode 100644 tests/AcDream.Core.Tests/Physics/WalkMissDiagnosticTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 7af44839..44f49b0b 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,9 +24,35 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. -## #435 — Probe debt: 17 temporary probes outlived their closed investigations, 14 more name no owner +## #435 — PARTLY CLOSED: Probe debt: 17 temporary probes outlived their closed investigations, 14 more name no owner -**Status:** OPEN +**Status:** The 17 orphaned probes are DELETED (2026-08-24) — 3,493 lines +removed, flag count 161 → 144, temporary probes 64 → 47. Build clean; full +hermetic suite 15,321 passed / 0 failed (baseline 15,333 minus the 12 tests +whose only subject was a deleted probe). Four files went entirely: +`WalkMissDiagnostic.cs`, `CollisionMeshWireframe.cs` and two probe-only test +files. `LaunchOptionsDocumentationTests` did its job during the cleanup — +it refused the deletion until the doc's rows moved to Retired and the frozen +direct-read counts were lowered (`PhysicsEngine.cs` to zero, +`TransitionTypes.cs` 3 → 2). + +Notable: `TransitionTypes.SetContactPlane` shed its `CallerMemberName` / +`CallerLineNumber` parameters, which existed only for #337's `cpSrc=` +attribution and were explicitly marked "strip with the probe family". No +call site passed them, so no behavior changed. F2's collision overlay +survives and reverts to the proxy-cylinder form, as intended when +`ACDREAM_WIRE_MESH` went. + +**STILL OPEN — the 14 unattributed probes.** They name no owning issue, so +nothing records when they are safe to remove. Deleting them on a guess is +how a future investigation loses apparatus it needed. The right next step is +attribution, not deletion: for each, find the commit that introduced it +(`git log -S ACDREAM_PROBE_X`), record the issue in its +`docs/launch-options.md` row, and only then decide. Deliberately deferred. + +**Original report follows.** + +**Status (original):** OPEN **Severity:** LOW (no runtime defect; hot-path clutter and measurement noise) **Filed:** 2026-08-24 (measured during the launch-options audit) **Component:** diagnostics ownership diff --git a/docs/launch-options.md b/docs/launch-options.md index de8c528b..d732c5fe 100644 --- a/docs/launch-options.md +++ b/docs/launch-options.md @@ -260,26 +260,24 @@ Each row names the issue that owns it. **A temporary probe is deleted in the same commit as its investigation's fix** — if you find one here whose issue is closed, the strip was missed; delete both. -> **Probe debt, measured 2026-08-24:** 64 temporary probes exist. They cite -> 21 distinct issues, and **14 of those are already closed** — 17 rows below -> are apparatus whose investigation ended without the strip. A further 14 -> rows name no owning issue at all, which is worse: nobody can tell when -> they are safe to remove. Tracked as [#435](ISSUES.md); do not add to the -> pile. Every probe here still costs a branch on its hot path even when -> unset, and a handful re-read the environment per frame rather than -> caching (see their side-effects column). +> **Probe debt, measured 2026-08-24:** 64 temporary probes existed, citing 21 +> distinct issues with 14 already closed. [#435](ISSUES.md) stripped the 17 +> rows whose investigation had ended without the strip — see the Retired +> section below for their removal record — leaving 47. A further 14 rows +> (unchanged by this pass) name no owning issue at all, which is worse: +> nobody can tell when they are safe to remove. Every probe here still costs +> a branch on its hot path even when unset, and a handful re-read the +> environment per frame rather than caching (see their side-effects column). | Flag | Owning investigation | Value | What it does | Side effects | Read by | |---|---|---|---|---|---| | `ACDREAM_A8_DUMP_PV` | (unattributed) | `=1` | Dumps local→NDC→clipped portal geometry (first 2 `Build` calls per distinct camera cell) | print-only (`Console.WriteLine`) | `PortalVisibilityBuilder.cs:270-271` (static field, not a diagnostics-owner class) | -| `ACDREAM_AIRBORNE_DIAG` | #42 | `=1` | prints `[SWEEP]`/`[SWEEP-OBJ]` lines tracing airborne-sweep XY drift, only when `!isOnGround` | print-only; re-reads the env var (`Environment.GetEnvironmentVariable`) on every airborne resolve/candidate instead of caching — minor per-call overhead when the flag is unset too | raw reads in `PhysicsEngine.cs:2300` + `TransitionTypes.cs:3905` (issue #42) | | `ACDREAM_CLIP_DEBUG` | #176 | `=1` | forces the EnvCell SHELL pass to map every instance to clip slot 0 (no-clip) instead of its cell's portal-slice region | ALTERS RENDERED OUTPUT: shells draw whole/unclipped instead of trimmed — a visual isolation mode, not a log-only probe; no DebugPanel mirror | `RenderingDiagnostics.ClipDebugNoShellTrim` | | `ACDREAM_DUMP_APPEARANCE` | #5 | `="1"` | Logs every `0xF625` ObjDescEvent + `0xF7DB` UpdateObject with body length, target guid, hex preview — used to debug remote-player appearance asymmetry | print-only (`Console.WriteLine`) | `WorldSession` static field `DumpAppearanceEnabled` (`WorldSession.cs:792-793`), raw scattered read, issue #5 diagnostic | | `ACDREAM_DUMP_CELLS` | #98 | `=` | one-shot JSON dump of any cached EnvCell whose id matches the list, to `ProbeDumpCellsPath` (issue #98 fixture capture) | file I/O once per matching cell id (no-op on repeat); fixture-generation tool, not a perf-neutral no-op when ids are listed | `PhysicsDiagnostics.ProbeDumpCellIds` (`ParseHexIdList`) | | `ACDREAM_DUMP_CELLS_DIR` | (unattributed) | `=` | overrides the output directory for `ACDREAM_DUMP_CELLS` | print/file-path only; no effect unless `ACDREAM_DUMP_CELLS` is also set | `PhysicsDiagnostics.ProbeDumpCellsPath` | | `ACDREAM_DUMP_CLOTHING` | (unattributed) | `=1` | Print-only: dumps clothing/part-swap diagnostics for a spawned entity when its setup has ≥10 mesh parts (humanoids). Gated additionally on part count even when the flag is on. | `print-only` | `RuntimeOptions.DumpClothing` → `DatLiveEntityProjectionMaterializer.cs:251-258,1190` | | `ACDREAM_DUMP_EDGE_SLIDE` | (unattributed) | `=1` | gates five `edge-slide:` trace lines (stepdown-failed, stepdown-branch-enter, phase2, branch, cliffslide) inside the L.4-diag edge-slide/cliff-slide code path | print-only; property re-reads `Environment.GetEnvironmentVariable` on EVERY call (not cached in a field) — repeated env lookups during edge-slide resolution when active; raw read outside any diagnostics-owner class (rule-5 candidate) | `Transition.DumpEdgeSlideEnabled` (private expression-bodied property in `TransitionTypes.cs`, raw read) | -| `ACDREAM_DUMP_ENTITY` | #119 | comma-separated hex ids, optional `0x` prefix, malformed segments ignored | per-entity HYDRATE/DRAW/WALK-REJECT trace for a watchlist of Setup/GfxObj source ids across `LandblockBuildFactory`, `WbDrawDispatcher` | print-only; every call site fast-exits on `Count==0`. The same id set is ALSO reused (undocumented in its own XML doc) as the watchlist for the `ACDREAM_PROBE_OUTSTAGE` `[outstage-own]` per-entity verdict probe — see Notes | `RenderingDiagnostics.DumpEntitySourceIds` | | `ACDREAM_DUMP_GFXOBJS` | #98 | `=` | one-shot JSON dump of any cached GfxObj's polygon table + BSP root metadata matching the list, to `ProbeDumpGfxObjsPath` (issue #98 fixture capture) | file I/O once per matching id (no-op on repeat) | `PhysicsDiagnostics.ProbeDumpGfxObjIds` (`ParseHexIdList`) | | `ACDREAM_DUMP_GFXOBJS_DIR` | (unattributed) | `=` | overrides the output directory for `ACDREAM_DUMP_GFXOBJS` | print/file-path only; no effect unless `ACDREAM_DUMP_GFXOBJS` is also set | `PhysicsDiagnostics.ProbeDumpGfxObjsPath` | | `ACDREAM_DUMP_LIVE_SPAWNS` | (unattributed) | `=1` | Print-only: logs every live `CreateObject` spawn as it's processed, plus DROP lines when a setup dat id is missing. | `print-only` | `RuntimeOptions.DumpLiveSpawns` → `DatLiveEntityProjectionMaterializer.cs:161-226`, `SessionPlayerComposition.cs:566,712` | @@ -294,7 +292,6 @@ issue is closed, the strip was missed; delete both. | `ACDREAM_DUMP_VITALS` | (unattributed) | `="1"` | Logs every `PrivateUpdateVital(Current)` parse, every parsed `PlayerDescription` (vector flags/attr/spell counts), and `PlayerDescriptionParser` trailer/mid-walk `FormatException` failures with position | print-only at every site. `PlayerDescriptionParser.cs:458/473` re-read the env var raw inside `catch` blocks on every parse failure (rare, but scattered/uncached). | Read independently (not shared) at 4 sites: `WorldSession.cs:790-791` (`DumpVitalsEnabled`), `GameEventWiring.cs:1041` (local `dumpPd` at PlayerDescription registration), `PlayerDescriptionParser.cs:458` and `:473` (per-catch-block raw reads). Also mirrored (display-only, non-functional) via `DebugPanel.cs:240`/`DebugVM.cs:225`. | | `ACDREAM_HIDE_PART` | (unattributed) | `=` | Hides one mesh part by index on entities with ≥10 parts (humanoids) — a debugging aid for equipment/clothing part-visibility issues. | Real (visible) behavior change, not print-only, but scoped to a single diagnostic index and off by default. | `RuntimeOptions.HidePartIndex` → `LivePresentationComposition.cs:608` → `LiveEntityAnimationPresenter.cs:21,38,243` | | `ACDREAM_LIGHT_DEBUG` | #176 | `=` (`int.TryParse`; unset/invalid → 0) | shader isolation mode uploaded as `uLightDebug` by `EnvCellRenderer` + `WbDrawDispatcher`: 0=off, 1=ambient-only vertex lighting, 2=kill dynamic point lights, 3=raw vLit visualization (texture ignored) | ALTERS RENDERED OUTPUT directly every draw pass (changes fragment-shader lighting/texturing) — not a log probe; no DebugPanel mirror | `RenderingDiagnostics.LightDebugMode` | -| `ACDREAM_PROBE_AUTOWALK` | issue #63 | `=1` | gates `[autowalk-out]`/`[autowalk-mt]`/`[autowalk-up]` lines in `LiveEntityNetworkUpdateController` tracing local-player server-initiated auto-walk (`SendUse`/`SendPickUp`, inbound `UpdateMotion`, inbound `UpdatePosition`) | print-only; filtered to local player only, low volume | `PhysicsDiagnostics.ProbeAutoWalkEnabled` | | `ACDREAM_PROBE_BUILDING` | l.2d slice 1 | `=1` | gates the multi-line `[resolve-bldg]` BSP-shadow-hit trace in `TransitionTypes.FindObjCollisions`, one-time `[entity-source]` registration logs in `GameWindow`, `[door-cycle]` UM dispatch trail, and a one-shot `[setstate-hex]` wire dump of the first `SetState` (0xF74B) packet in `WorldSession` | print-only; also un-gates the `PhysicsDiagnostics.LastBspHitPoly` diagnostic side-channel (a static field write in `BSPQuery`/`FlatBspQuery`, read back by the `[resolve-bldg]` line) — no gameplay effect, but an extra static-field write per BSP hit while on; heavy output (one multi-line entry per BSP hit per physics tick) | `PhysicsDiagnostics.ProbeBuildingEnabled` | | `ACDREAM_PROBE_CELL` | (unattributed) | `=1` | gates one `[cell-transit]` line per `PlayerMovementController.CellId` change (old→new cell, position, reason tag) | print-only; low volume (only on actual cell crossings) | `PhysicsDiagnostics.ProbeCellEnabled` | | `ACDREAM_PROBE_CELLSET` | a6.p5 | `=1` | gates `PhysicsDiagnostics.LogCellSetBuild`, one `[cellset-build]` line per `BuildCellSetAndPickContaining` call (seed cell, sphere XY, candidate list) from `CellTransit.cs:1468` | print-only; builds a `StringBuilder` of the candidate id list only when the flag is on | `PhysicsDiagnostics.ProbeCellSetEnabled` | @@ -308,34 +305,19 @@ issue is closed, the strip was missed; delete both. | `ACDREAM_PROBE_INDOOR_BSP` | indoor walking phase 1 / cellar-lip wedge | `=1` | gates `[indoor-bsp]` (per `BSPQuery.FindCollisions` indoor call), `[neg-poly]` (near-miss polygon detail in `BSPQuery`), and `[stepdown-decide]` (step-down accept/reject inputs in `TransitionTypes`) trace lines | print-only; also un-gates the `LastBspHitPoly` diagnostic side-channel write (same as `ACDREAM_PROBE_BUILDING`) | `PhysicsDiagnostics.ProbeIndoorBspEnabled` | | `ACDREAM_PROBE_INDOOR_LIGHT` | #176/#177 discriminator, a7.l1 | `=1` | rate-limited (1 Hz) `[indoor-light]` line from `LightManager.BuildPointLightSnapshot`: point-light pool set composition (pool/cellLess/registered/capped/byCell histogram) | print-only, explicitly "inert unless set" per the call-site comment (LightManager.cs:368-370); no DebugPanel mirror | `RenderingDiagnostics.ProbeIndoorLightEnabled` | | `ACDREAM_PROBE_JUMP` | campaign ch round 2 | `=1` | gates the `[jump]` line in `PlayerMovementController.ReportJumpRefusal`, printed UNCONDITIONALLY (even when `OnInterfaceText` is null) to distinguish "branch never fired" from "branch fired, callback dropped it" | print-only; `Headless/Policies/HeadlessBotPolicy.cs`'s `JumpProbeHeadlessBotPolicy` doc comment references this flag as a companion but does not itself read it — it is a headless bot behavior meant to be run alongside `ACDREAM_PROBE_JUMP=1`, not a second consumer | `PhysicsDiagnostics.ProbeJumpEnabled` | -| `ACDREAM_PROBE_LIGHT` | #133 a7 | `=1` | rate-limited (1 Hz) `[light]` line + up to 3 `[light-detail]` lines: scene ambient/sun/registered/active light counts and nearest active point/spot light detail | print-only ("Output-only, inert when off" per doc) | `RenderingDiagnostics.ProbeLightEnabled` | | `ACDREAM_PROBE_LOCAL_TELEPORT` | c4 route 3 d-t8 | `=1` | gates one `[local-tp]` line per local-player portal-arrival attempt (committed AND refused) from `LocalPlayerTeleportController` and `RuntimeAcceptedPositionDriveController.LogPortalArrivalAttempt` — the single Runtime chokepoint both graphical and headless hosts share | print-only; dual-host parity evidence (same line shape from both hosts) | `PhysicsDiagnostics.ProbeLocalTeleportEnabled` | -| `ACDREAM_PROBE_OUTSTAGE` | #131 | `=1` | print-on-change `[outstage]` line (outside-stage routing + per-slice cone verdicts) from `RetailPViewRenderer`; plus, when `ACDREAM_DUMP_ENTITY` also names watched ids, `[outstage-own]` per-entity PASS/CULL lines | print-only | `RenderingDiagnostics.ProbeOutStageEnabled` | | `ACDREAM_PROBE_PARK` | issue #309 | `=1` | gates `[park]`/`[park-restore]` lines when a `RuntimeSetPositionState` placement parks or a cancelled park's withdrawal is rolled back | print-only, low volume (parks are rare); in a MULTI-session headless host, `HeadlessStaticStateAudit.ValidateProcessIsolation` THROWS `HeadlessConfigurationException` at startup if this (or any other process-global `Probe*`/`Dump*` boolean, `CollisionShadowSampleEvery`, or `PhysicsResolveCapture`) is enabled — refusal is waived only when `sessionCount==1` (logs loudly and proceeds instead) | `PhysicsDiagnostics.ProbeParkEnabled` | -| `ACDREAM_PROBE_PHANTOM` | #113, "throwaway apparatus — strip when the phantom closes" | `=1` | print-on-change `[phantom-shell]` / `[phantom-objs]` lines identifying which draw mechanism (shell pass vs. entity list) draws geometry unclipped/un-viewcone'd per cell | print-only | `RenderingDiagnostics.ProbePhantomEnabled` | | `ACDREAM_PROBE_PLACEMENT_FAIL` | issue #98 | `=1` | gates one `[place-fail]` line per Path-1 (Placement/Ethereal) `Collided` return in `BSPQuery.FindCollisions`, plus one per `Transition.DoStepDown` placement-insert rejection | print-only; low volume (fires only on actual rejection) | `PhysicsDiagnostics.ProbePlacementFailEnabled` | | `ACDREAM_PROBE_POLY_DUMP` | a6.p3 slice 4, issue #98 | `=1` | gates one `[poly-dump]` line (full polygon geometry: cell, poly index, sides, plane, all vertices) per `AdjustSphereToPlane` push-back call | print-only; HEAVY output (one full-geometry dump per push-back call) — doc explicitly says "use briefly, then turn off" | `PhysicsDiagnostics.ProbePolyDumpEnabled` | | `ACDREAM_PROBE_PORTAL_CHURN` | "throwaway apparatus — strip once the bound ships" | `=1` | one `[portal-churn]` summary per `PortalVisibilityBuilder.Build` call: per-cell pop/re-pop counts, re-enqueue totals, reciprocal-clip pre→post region growth | print-only | `RenderingDiagnostics.ProbePortalChurnEnabled` | | `ACDREAM_PROBE_PUSH_BACK` | phase a6.p1 | `=1` | gates `[push-back]` (`BSPQuery.AdjustSphereToPlane`), `[push-back-disp]` (`BSPQuery.FindCollisions` 6-path dispatcher), `[push-back-cell]` (`Transition.CheckOtherCells` multi-cell BSP) lines | print-only; the `DebugVM.cs:380` "runtime mirror" is dead code — `DebugVM`/`DebugPanel` (`AcDream.UI.Abstractions/Panels/Debug/`) are never instantiated anywhere in `src/` (the ImGui frontend they required was removed at Campaign V slice V11); only the startup env var takes effect | `PhysicsDiagnostics.ProbePushBackEnabled` | | `ACDREAM_PROBE_PVINPUT` | "throwaway apparatus — strip once the jitter source is pinned" | `=1` | one `[pv-input]` line/frame with 6-dp-precision `PortalVisibilityBuilder.Build` inputs (camera eye, player position, VP elements) + resulting flood-cell count; deliberately runs WITHOUT the heavier `[flap]` probe so the log stays diffable | print-only | `RenderingDiagnostics.ProbePvInputEnabled` | -| `ACDREAM_PROBE_REACH` | #334, temporary — strip with the probe family | `=1` | gates `[reach-q]` (per-cell candidate-disposition query summary, emitted even on zero-entry cells) and `[reach-obj]` (per-candidate disposition: exempt/no-shape/bsp-only-skip/tested) lines in `Transition.FindObjCollisionsInCell` | print-only; runs on a HOT path (per cell per transitional insert); de-duplicated via two `Dictionary` caches with a `lock`-protected gate (`_reachGate`) — a real per-call dictionary lookup + occasional lock contention while on, bounded emission (≤2/sec per cell, ≤1/sec per candidate) | `PhysicsDiagnostics.ProbeReachEnabled` | -| `ACDREAM_PROBE_REMOTE_LANDING` | bug a / issue #32, temporary | `=1` | gates `[remote-landing]`/`[remote-landing-gate]`/`[remote-landing-after]` lines around remote ground-contact edges in `LiveEntityNetworkUpdateController` and `RuntimeRemotePhysicsUpdater`; `MotionTableDispatchSink.ApplyMotion` unconditionally forwards its result to `PhysicsDiagnostics.RecordRemoteLandingDispatch` (self-guarded internally, no behavior change) | print-only; uses `[ThreadStatic]` capture latches (`_remoteLandingApplyCalls` etc.) so a headless host ticking several sessions in parallel doesn't cross-contaminate | `PhysicsDiagnostics.ProbeRemoteLandingEnabled` | | `ACDREAM_PROBE_REMOTE_SLIDE` | bug b, temporary — strip once two-client roof capture lands | `=1` OR `=` | gates `[remote-slide-up]`/`[remote-slide-vec]`/`[remote-slide-snap]`/`[remote-slide-enq]` lines across `LiveEntityNetworkUpdateController`, `InterpolationManager`, `RuntimeRemotePhysicsUpdater`, `RuntimeRemoteSteadyStatePosition` tracing two candidate remote-slide "blip" producers | print-only; `BeginRemoteSlideAttribution`/GUID-stamping calls are UNCONDITIONAL at several call sites (self-guard is internal), so a `[ThreadStatic]` field write happens on every remote tick regardless of the flag (cheap, non-allocating); a GUID allow-list narrows output to specific entities for a readable two-client capture | `PhysicsDiagnostics.ProbeRemoteSlideEnabled` + `ProbeRemoteSlideGuids` (raw string parsed via `ParseHexIdList` unless it's the literal `"1"`) | | `ACDREAM_PROBE_REMOTE_TELEPORT` | c4 route 4b-3, temporary | `=1` | gates one `[remote-teleport]` line per routed remote teleport arm in `LiveEntityNetworkUpdateController.ApplyRemoteContactRouting` | print-only; a 2026-08-04 fix moved the enabled-check to the CALL SITE because the probe's internal self-guard did not prevent `teleportStatus.ToString()` from being evaluated/allocated on every teleport regardless of flag state — now properly guarded | `PhysicsDiagnostics.ProbeRemoteTeleportEnabled` | | `ACDREAM_PROBE_SEAMDRAW` | #176, "throwaway apparatus" | `"1"`/`"true"`/blank → default #176 Facility Hub cell set (7 fixed hex ids); otherwise comma-separated hex cell-id list | change-deduped + 2 s-heartbeat `[seam-cell]`/`[seam-snap]`/`[seam-ent]`/`[seam-mask]` lines from `EnvCellRenderer.Render` and `WbDrawDispatcher` describing per-instance transforms and resolved light-set identities at target cells | print-only | `RenderingDiagnostics.ProbeSeamDrawEnabled` / `SeamDrawTargetCells` | -| `ACDREAM_PROBE_SHELL` | #78, "throwaway apparatus — strip once the indoor-enclosure render is fixed" | `=1` | one `[shell]` line per opaque-pass `EnvCellRenderer.Render` call: per filtered cell — snapshot presence, gfxObj/batch/index/translucent/zero-bindless-handle counts | print-only; allocates a `StringBuilder` and loops every visible cell on every opaque pass while enabled | `RenderingDiagnostics.ProbeShellEnabled` | -| `ACDREAM_PROBE_STEP_HEIGHTS` | issue #338 | `=1` | gates edge-triggered `[step-h]` lines at `prepare`/`publish`/`resolve` sites tracing step-up/step-down height provenance | print-only; `AnnounceStepHeightProbeOnce` prints TWO self-report lines EXACTLY ONCE PER PROCESS regardless of the flag's value (reports the flag's own state + the raw env var text + the running assembly's file path) — this self-report line is NOT gated by the flag itself, only rate-limited to once | `PhysicsDiagnostics.ProbeStepHeightsEnabled` | | `ACDREAM_PROBE_STEP_WALK` | a6.p3 issue #98 | `=1` | gates `[step-walk]` lines at select points in the transition sub-step loop and step-down probe (requested vs adjusted offset, sphere positions, contact planes, walkable flags) | print-only; no DebugPanel mirror | `PhysicsDiagnostics.ProbeStepWalkEnabled` | -| `ACDREAM_PROBE_STICKY` | r5-v3 issue #171 | `=1` | gates `[sticky]` lifecycle lines (STICK/UNSTICK/LEASE-EXPIRE/TARGET-status teardown) and per-armed-tick steer lines in `AcDream.Core.Physics.Motion.StickyManager`, plus `[sticky-snap-skip]` in `LiveEntityNetworkUpdateController` when a server hard-snap is suppressed for a stuck entity | print-only; heavy while a pack is stuck (~60 Hz × stuck count) | `PhysicsDiagnostics.ProbeStickyEnabled` | -| `ACDREAM_PROBE_SUPPORT` | issue #337, temporary | `=1` | gates `[support]` (per resolve, per body INCLUDING corpses/NPCs — independent terrain sample at the body's out-XY compared against the contact plane's height/provenance) and `[geom]` (once per nearby GfxObj — physics-BSP vertex cloud vs visual mesh AABB coincidence verdict) lines in `PhysicsEngine` | print-only; `[support]` performs an INDEPENDENT terrain height sample every time it fires (real extra computation beyond the resolve itself, throttled to 4 Hz per body plus every 10 cm of vertical movement); pure reads only, never mutates production collision state | `PhysicsDiagnostics.ProbeSupportEnabled` | | `ACDREAM_PROBE_SWEPT` | phase w stage 0 | `=1` | gates one `[cell-swept]` line per `ResolveWithTransition` call comparing the transition's swept cell vs the legacy static `ResolveCellId` path | print-only | `PhysicsDiagnostics.ProbeSweptEnabled` | | `ACDREAM_PROBE_TELEPORT` | 2026-06-22, "removable diagnostic" | `=1` | gates `[tp-probe]` lines (`LogTeleport`) at AIM/ENQ/BUILD/APPLY/PLACED teleport-pipeline events across `LocalPlayerTeleportController` and `RuntimeAcceptedPositionDriveController`, with cross-thread monotonic timestamps | print-only | `PhysicsDiagnostics.ProbeTeleportEnabled` | -| `ACDREAM_PROBE_TEXFLUSH` | #105 | `=1` | one `[tex-flush]` line whenever `WbMeshAdapter.Tick`'s staged-texture-update picture changes (pending layer updates before/after the per-frame mipmap flush) | print-only | `RenderingDiagnostics.ProbeTexFlushEnabled` | -| `ACDREAM_PROBE_VIEWER` | #119-residual | `=1` | one `[viewer]` line per CHANGE of (root cell, flood size, OutsideView poly count, player cell), with mm-precision projection eye — capture half of the tower-ascent capture→replay loop (`TowerAscentReplayTests`) | print-only | `RenderingDiagnostics.ProbeViewerEnabled` | -| `ACDREAM_PROBE_WALK_MISS` | issues #83, spike-only | `=1` | gates `[walk-miss]` (per `Transition.TryFindIndoorWalkablePlane` MISS) and `[floor-polys]` (per indoor cell cached, enumerating walkable-eligible polygons) lines | print-only; no DebugPanel mirror | `PhysicsDiagnostics.ProbeWalkMissEnabled` | -| `ACDREAM_WIRE_MESH` | #337, explicitly "temporary" | `=1` | when the separate F2 collision-wireframe overlay is already active, replaces its cheap broadphase-proxy-cylinder drawing with the object's REAL physics-BSP polygon edges (cyan) + visual mesh AABB (magenta) + terrain triangle under the player (yellow), resolved live every frame | ALTERS RENDERED OUTPUT (debug overlay geometry): adds real per-frame physics-BSP polygon extraction + line-drawing cost while F2 is on; only takes effect when the separate F2 toggle (`_state.CollisionWireframesVisible`) is also enabled; also emits a throttled print-on-change stats line | `RenderingDiagnostics.CollisionMeshWireframeEnabled` | -| `ACDREAM_WIRE_RADIUS` | companion knob to #337/`acdream_wire_mesh` | `=` (`float.TryParse`, invariant culture; falls back to 30 if unparsable or ≤0) | sets the radius around the player within which `CollisionMeshWireframeEnabled` resolves polygon geometry | larger radius = more physics-BSP polygon extraction/line-drawing cost per frame; only matters while `ACDREAM_WIRE_MESH=1` | `RenderingDiagnostics.CollisionMeshWireframeRadius` | - ## Deprecated | Flag | Value | What it does | Side effects | Default | Read by | @@ -360,3 +342,20 @@ from the "must still exist" check. | `ACDREAM_RENDER_BACKEND` | Selected the GL-vs-Vulkan backend. Campaign V deleted the OpenGL backend; Vulkan is the only one. Two comments still named it as a live co-requisite until 2026-08-24. | none | | `ACDREAM_ANIM_SPEED_SCALE` | Animation-speed multiplier from the pre-retail-sequencer era; died with the 1.248x factor. | none | | `ACDREAM_A8_AUDIT` | Phase A8 EnvCell batch/cull audit dump. Its only caller never existed; `EnvCellRenderer.CollectCellAuditLines` was unreachable and was deleted 2026-08-24. | `ACDREAM_PROBE_ENVCELL` | +| `ACDREAM_AIRBORNE_DIAG` | #42 airborne-sweep `[SWEEP]`/`[SWEEP-OBJ]` XY-drift trace. Investigation closed; stripped 2026-08-24 (#435) along with its 16 siblings below. | none | +| `ACDREAM_DUMP_ENTITY` | #119 tower-staircase HYDRATE/DRAW/WALK-REJECT entity watchlist. Investigation closed; stripped 2026-08-24 (#435). | none | +| `ACDREAM_PROBE_AUTOWALK` | Issue #63 server-initiated auto-walk trace. Investigation closed; stripped 2026-08-24 (#435). | none | +| `ACDREAM_PROBE_LIGHT` | #133 A7 dungeon-lighting `[light]`/`[light-detail]` trace. Investigation closed; stripped 2026-08-24 (#435). | none | +| `ACDREAM_PROBE_OUTSTAGE` | #131 outside-stage dynamics routing trace (also the `ACDREAM_DUMP_ENTITY` `[outstage-own]` watchlist consumer). Investigation closed; stripped 2026-08-24 (#435). | none | +| `ACDREAM_PROBE_PHANTOM` | #113 phantom-shell/phantom-objs draw-mechanism trace. Investigation closed; stripped 2026-08-24 (#435). | none | +| `ACDREAM_PROBE_REACH` | #334 broadphase candidate-disposition trace. Investigation closed; stripped 2026-08-24 (#435). | none | +| `ACDREAM_PROBE_REMOTE_LANDING` | Bug A / issue #32 remote ground-contact landing trace. Investigation closed; stripped 2026-08-24 (#435). | none | +| `ACDREAM_PROBE_SHELL` | #78 cell-shell opaque-pass render trace. Investigation closed; stripped 2026-08-24 (#435). | none | +| `ACDREAM_PROBE_STEP_HEIGHTS` | Issue #338 step-up/step-down height provenance trace (including its unconditional once-per-process `AnnounceStepHeightProbeOnce` self-report). Investigation closed; stripped 2026-08-24 (#435). | none | +| `ACDREAM_PROBE_STICKY` | R5-V3 issue #171 sticky-melee lifecycle/steer trace. Investigation closed; stripped 2026-08-24 (#435). | none | +| `ACDREAM_PROBE_SUPPORT` | Issue #337 `[support]`/`[geom]` collision-vs-visual classifier trace. Investigation closed; stripped 2026-08-24 (#435). | none | +| `ACDREAM_PROBE_TEXFLUSH` | #105 white-indoor-textures staged-upload trace. Investigation closed; stripped 2026-08-24 (#435). | none | +| `ACDREAM_PROBE_VIEWER` | #119-residual viewer/flood capture (tower-ascent replay). Investigation closed; stripped 2026-08-24 (#435). | none | +| `ACDREAM_PROBE_WALK_MISS` | Issue #83 indoor walkable-plane miss trace. Investigation closed; stripped 2026-08-24 (#435). | none | +| `ACDREAM_WIRE_MESH` | Issue #337 F2 overlay upgrade to real physics-BSP polygon edges. Investigation closed; stripped 2026-08-24 (#435) — F2 reverted to its proxy-cylinder overlay. | none | +| `ACDREAM_WIRE_RADIUS` | Companion radius knob for `ACDREAM_WIRE_MESH`. Stripped alongside it 2026-08-24 (#435). | none | diff --git a/src/AcDream.App/Input/PlayerModeController.cs b/src/AcDream.App/Input/PlayerModeController.cs index faa2e1d5..36665902 100644 --- a/src/AcDream.App/Input/PlayerModeController.cs +++ b/src/AcDream.App/Input/PlayerModeController.cs @@ -332,8 +332,6 @@ internal sealed class PlayerModeController : { moveTo.MoveToComplete = error => { - if (PhysicsDiagnostics.ProbeAutoWalkEnabled) - Console.WriteLine($"[autowalk-end] reason=complete err={error}"); if (error == WeenieError.None) approachLifetime.PublishNaturalCompletion(); else diff --git a/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs b/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs index 5a7f463d..34506cde 100644 --- a/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs +++ b/src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs @@ -229,14 +229,6 @@ internal sealed class LiveEntityMotionRuntimeController // CObjectMaint's object table and must still resolve here. if (liveEntities.IsHidden(id)) { - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - { - Console.WriteLine( - $"[autowalk-host-miss] object=0x{id:X8} " - + $"materialized={_liveEntities.ContainsWorldEntity(id)} " - + $"registered={liveEntities.TryGetPhysicsHost(id, out _)} " - + $"hidden={liveEntities.IsHidden(id)}"); - } return null; } if (liveEntities.TryGetPhysicsHost(id, out var existing)) @@ -494,26 +486,6 @@ internal sealed class LiveEntityMotionRuntimeController } } movement.PerformMovement(ms); - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - { - string target = turnPath.TargetGuid is { } targetGuid - ? $"0x{targetGuid:X8}" : "null"; - bool targetVisible = turnPath.TargetGuid is { } visibleGuid - && _liveEntities.TryGetInteractionEligibleEntity( - visibleGuid, - out _); - bool targetHost = turnPath.TargetGuid is { } hostGuid - && _liveEntities?.TryGetPhysicsHost(hostGuid, out _) == true; - var moveTo = movement.MoveTo; - Console.WriteLine( - $"[autowalk-turn-route] wire=0x{update.MotionState.MovementType:X2} " - + $"routed={ms.Type} target={target} visible={targetVisible} " - + $"host={targetHost} stop={mp.StopCompletelyFlag} " - + $"initialized={moveTo?.Initialized ?? false} " - + $"nodes={moveTo?.PendingActions.Count() ?? 0} " - + $"command=0x{moveTo?.CurrentCommand ?? 0u:X8} " - + $"pendingMotions={movement.Minterp.MotionsPending()}"); - } return true; } diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index 040b11f1..e7722f2c 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -619,28 +619,6 @@ internal sealed class LiveEntityNetworkUpdateController // the exact swing and carries it in Commands[]. if (update.Guid == _playerServerGuid) { - // B.6 slice 1 (2026-05-14): trace inbound motion for the - // local player. One line per inbound UM, gated on - // ACDREAM_PROBE_AUTOWALK=1 (name kept through R4-V5). - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - { - string cmdHex = command.HasValue ? $"0x{command.Value:X4}" : "null"; - string pathStr = update.MotionState.MoveToPath is { } p - ? $"path=cell=0x{p.OriginCellId:X8},xyz=({p.OriginX:F2},{p.OriginY:F2},{p.OriginZ:F2}),minDist={p.MinDistance:F2},objDist={p.DistanceToObject:F2}" - : "path=null"; - string spd = update.MotionState.ForwardSpeed is { } fs - ? $"fwdSpd={fs:F2}" - : "fwdSpd=null"; - string mtsSpd = update.MotionState.MoveToSpeed is { } ms - ? $"mtSpd={ms:F2}" - : "mtSpd=null"; - string mtsRun = update.MotionState.MoveToRunRate is { } mr - ? $"mtRun={mr:F2}" - : "mtRun=null"; - Console.WriteLine(System.FormattableString.Invariant( - $"[autowalk-mt] stance=0x{stance:X4} cmd={cmdHex} mt=0x{update.MotionState.MovementType:X2} isMoveTo={update.MotionState.IsServerControlledMoveTo} moveTowards={update.MotionState.MoveTowards} {pathStr} {spd} {mtsSpd} {mtsRun}")); - } - // R4-V5: retail unpack_movement dispatch for the local // player — the SAME shape the remote branch uses below. // Head (@300566): interrupt + unstick fire for EVERY @@ -700,11 +678,6 @@ internal sealed class LiveEntityNetworkUpdateController } if (localDispatch.RoutedMoveTo) { - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - { - Console.WriteLine(System.FormattableString.Invariant( - $"[autowalk-begin] mt=0x{update.MotionState.MovementType:X2} movingTo={_playerController.Movement.IsMovingTo()} type={_playerController.MoveTo?.MovementTypeState}")); - } return; } if (!localDispatch.AppliedInterpretedState) @@ -2128,21 +2101,6 @@ internal sealed class LiveEntityNetworkUpdateController if (update.Guid == _playerServerGuid) _authorityGate.ObserveAcceptedLocalPosition(update.Position.LandblockId); - // B.6 slice 1 (2026-05-14): trace inbound UpdatePosition cadence for - // the local player. Combined with [autowalk-mt] this answers - // whether ACE's broadcast frequency during a server-initiated - // auto-walk is dense enough to drive smooth visible motion (the - // Option C viability check from the design spec). Gated on - // ACDREAM_PROBE_AUTOWALK=1; skips remote entities. - if (update.Guid == _playerServerGuid - && AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - { - string velStr = update.Velocity is { } v - ? $"vel=({v.X:F2},{v.Y:F2},{v.Z:F2})" - : "vel=null"; - Console.WriteLine(System.FormattableString.Invariant( - $"[autowalk-up] cell=0x{p.LandblockId:X8} pos=({p.PositionX:F2},{p.PositionY:F2},{p.PositionZ:F2}) world=({worldPos.X:F2},{worldPos.Y:F2},{worldPos.Z:F2}) {velStr} grounded={update.IsGrounded}")); - } var rot = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition ? entity.Rotation : new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW); @@ -2685,14 +2643,6 @@ internal sealed class LiveEntityNetworkUpdateController // first UP after unstick (bounded by the 1 s sticky lease). bool snapSuppressedByStick = !IsPlayerGuid(update.Guid) && (rmState.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u; - if (snapSuppressedByStick - && AcDream.Core.Physics.PhysicsDiagnostics.ProbeStickyEnabled) - { - float snapDist = System.Numerics.Vector3.Distance( - worldPos, rmState.Body.Position); - Console.WriteLine(FormattableString.Invariant( - $"[sticky-snap-skip] guid=0x{update.Guid:X8} d={snapDist:F3} srv=({worldPos.X:F2},{worldPos.Y:F2}) body=({rmState.Body.Position.X:F2},{rmState.Body.Position.Y:F2})")); - } // C4 routes 4a + 4b-2 + 4b-3 collapse: the complete near/far/ // teleport/leftover decision — including the dissolved LANDING @@ -2796,43 +2746,6 @@ internal sealed class LiveEntityNetworkUpdateController _motionRuntime.EnsureRemoteMotionBindings( rmState, aeForLand, update.Guid); } - - // Bug A investigation (2026-08-04, docs/ISSUES.md #32): - // the packet-side half of the landing capture, now fired - // for both guids (diagnostic-only — TEMPORARY, strip with - // the probe family; not behaviour). - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeRemoteLandingEnabled) - { - bool gravitySetForProbe = rmState.Body.HasGravity; - AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLanding( - site: "controller", - guid: update.Guid, - airborneBefore: true, - gravitySet: gravitySetForProbe, - contact: rmState.Body.InContact, - onWalkable: rmState.Body.OnWalkable, - hasDefaultSink: rmState.Motion.DefaultSink is not null, - resolveIsOnGround: null, - sequencerStyle: aeForLand?.Sequencer?.CurrentStyle ?? 0, - sequencerMotion: aeForLand?.Sequencer?.CurrentMotion ?? 0); - if (!gravitySetForProbe) - { - AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLandingGateNoOp( - "controller", update.Guid); - } - // Zero the sink-dispatch latches before reading them - // back — nothing at THIS site dispatches (the arming - // call lives only next to the per-tick HitGround). - AcDream.Core.Physics.PhysicsDiagnostics - .BeginRemoteLandingDispatchCapture(); - AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLandingAfter( - site: "controller", - guid: update.Guid, - hitGroundInvoked: false, - sequencerStyle: aeForLand?.Sequencer?.CurrentStyle ?? 0, - sequencerMotion: aeForLand?.Sequencer?.CurrentMotion ?? 0, - forwardCommand: rmState.Motion.InterpretedState.ForwardCommand); - } } } diff --git a/src/AcDream.App/Rendering/CollisionMeshWireframe.cs b/src/AcDream.App/Rendering/CollisionMeshWireframe.cs deleted file mode 100644 index 5954e4a8..00000000 --- a/src/AcDream.App/Rendering/CollisionMeshWireframe.cs +++ /dev/null @@ -1,372 +0,0 @@ -using System.Collections.Immutable; -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Rendering; - -namespace AcDream.App.Rendering; - -/// -/// #337 collision-mesh wireframe (2026-08-06 — TEMPORARY, strip with the #337 -/// probe family). -/// -/// -/// The F2 collision overlay predating this class drew, for a BSP object, a -/// proxy cylinder sized from the object's registered BROADPHASE radius. That -/// answers "where does the collision system think this object roughly is" and -/// nothing more. The open question in Neftet is a different one — whether an -/// object's collision SURFACES are where its visual mesh is drawn — and a -/// proxy sphere cannot answer it in either direction. -/// -/// -/// -/// This draws the actual geometry instead, in three colours that are meant to -/// be read against each other: -/// -/// Cyan — the object's real physics-BSP polygon edges, in world -/// space. These are the surfaces a body can stand on or be stopped by. -/// Where the cyan mesh sits away from the rock you can see, the collision -/// is displaced; where a visible rock has no cyan on it at all, it has no -/// collision geometry. -/// Magenta — the same object's VISUAL mesh bounding box, from -/// the same prepared assets the renderer draws from. It is the reference -/// the cyan is judged against, so the comparison does not depend on the -/// eye's guess about where the visual "really" is. -/// Yellow — the outdoor terrain surface under the player, as a -/// grid of the physics engine's own sampled heights. If a body is resting -/// on the yellow rather than on cyan, terrain is holding it up and the -/// object's collision is not involved at all. -/// -/// Dim orange keeps the old broadphase proxy visible so nothing the previous -/// overlay showed has been taken away. -/// -/// -/// -/// Geometry is resolved through the SAME prepared collision accessors the -/// resolver queries (PhysicsDataCache.GetFlatGfxObj / -/// GetVisualBounds) and placed with the SAME world transform the -/// collision probes use, so this cannot draw a shape the collision system does -/// not actually hold. Reading the geometry by a second route is how AP-156 -/// managed to report a sphere the registry never emitted. -/// -/// -/// -/// Pure reads. Nothing here mutates physics, registry, or render state; the -/// caller owns the frame. -/// -/// -internal sealed class CollisionMeshWireframe -{ - // Colours, in the order the class comment lists them. - private static readonly Vector3 PhysicsColor = new(0f, 1f, 1f); - private static readonly Vector3 VisualColor = new(1f, 0f, 1f); - private static readonly Vector3 TerrainColor = new(1f, 1f, 0f); - private static readonly Vector3 BroadphaseColor = new(0.45f, 0.22f, 0f); - - /// - /// Per-frame line ceiling. Each line is 48 bytes in the debug renderer's - /// ring allocation, so this caps the overlay at ~1.9 MB a frame. Landblock - /// 0x8766 carries the largest single collision owner measured in the game - /// (an 81-cell footprint), and an uncapped walk of it would be the one - /// place this overlay falls over. - /// - private const int MaxLines = 40_000; - - /// Per-object polygon ceiling, so one enormous formation cannot - /// consume the whole budget and hide every other object near it. - private const int MaxPolygonsPerObject = 4_000; - - /// Half-width in metres of the terrain grid drawn under the - /// player, and its sample spacing. - private const float TerrainGridHalfWidth = 12f; - private const float TerrainGridStep = 2f; - - private readonly PhysicsEngine _physics; - - public CollisionMeshWireframe(PhysicsEngine physics) - => _physics = physics ?? throw new ArgumentNullException(nameof(physics)); - - /// - /// Emit the overlay for everything within - /// of - /// . Returns what it drew so the caller can - /// report a capped frame rather than silently showing partial geometry. - /// - public CollisionMeshWireframeStats Draw(DebugLineRenderer lines, Vector3 centre) - { - ArgumentNullException.ThrowIfNull(lines); - - float radius = RenderingDiagnostics.CollisionMeshWireframeRadius; - float radiusSquared = radius * radius; - var budget = new LineBudget(MaxLines); - - int objects = 0; - int polygons = 0; - int withoutGeometry = 0; - - PhysicsDataCache? cache = _physics.DataCache; - - foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug()) - { - // Objects register their part ORIGIN, which for a BSP part is - // routinely nowhere near the geometry itself (376 of the 973 - // installed physics-BSP parts sit further from their own bounding - // centre than half their radius). Admitting on origin distance - // ALONE would drop exactly the large displaced-centre formations - // this overlay exists to look at, so the object's own radius is - // added to the window. - float reach = radius + shadow.Radius; - if (Vector3.DistanceSquared(shadow.Position, centre) > reach * reach) - continue; - - objects++; - - if (shadow.CollisionType != ShadowCollisionType.BSP) - { - DrawBroadphaseProxy(lines, in shadow, budget); - continue; - } - - FlatGfxObjCollisionAsset? asset = cache?.GetFlatGfxObj(shadow.GfxObjId); - int drawn = DrawPhysicsPolygons(lines, in shadow, asset, centre, radiusSquared, budget); - polygons += drawn; - if (drawn == 0) withoutGeometry++; - - DrawVisualBounds(lines, in shadow, cache?.GetVisualBounds(shadow.GfxObjId), budget); - DrawBroadphaseProxy(lines, in shadow, budget); - } - - DrawTerrainGrid(lines, centre, budget); - - return new CollisionMeshWireframeStats( - ObjectsConsidered: objects, - PolygonsDrawn: polygons, - ObjectsWithoutPhysicsGeometry: withoutGeometry, - LinesDrawn: budget.Used, - Capped: budget.Capped); - } - - /// - /// Walk the object's physics BSP and emit one closed edge loop per polygon - /// the tree actually indexes. Polygons the tree does not reference are NOT - /// drawn: no query can reach them, so showing them would overstate the - /// collision surface. Returns the polygon count emitted. - /// - private static int DrawPhysicsPolygons( - DebugLineRenderer lines, - in ShadowEntry shadow, - FlatGfxObjCollisionAsset? asset, - Vector3 centre, - float radiusSquared, - LineBudget budget) - { - FlatPhysicsBsp? bsp = asset?.PhysicsBsp; - if (bsp is not { RootIndex: >= 0 } || bsp.Nodes.Length == 0) - return 0; - - FlatPolygonTable table = bsp.PolygonTable; - ImmutableArray vertices = table.Vertices; - int emitted = 0; - - foreach (FlatPhysicsBspNode node in bsp.Nodes) - { - FlatIndexRange indices = node.PolygonIndexRange; - for (int i = indices.Start; i < indices.EndExclusive; i++) - { - if (emitted >= MaxPolygonsPerObject || budget.Exhausted) - return emitted; - - int polygonIndex = bsp.PolygonIndexStream[i]; - if ((uint)polygonIndex >= (uint)table.Polygons.Length) continue; - - FlatIndexRange span = table.Polygons[polygonIndex].VertexRange; - if (span.Count < 2) continue; - - Vector3 first = ToWorld(vertices[span.Start], in shadow); - // Per-polygon distance rejection, AFTER the world transform: - // a big object admitted by the object-level window still only - // needs the faces near the player drawn. - if (Vector3.DistanceSquared(first, centre) > radiusSquared) continue; - - Vector3 previous = first; - for (int v = span.Start + 1; v < span.EndExclusive; v++) - { - Vector3 current = ToWorld(vertices[v], in shadow); - if (!budget.TryAdd()) return emitted; - lines.AddLine(previous, current, PhysicsColor); - previous = current; - } - - if (span.Count > 2) - { - if (!budget.TryAdd()) return emitted; - lines.AddLine(previous, first, PhysicsColor); - } - - emitted++; - } - } - - return emitted; - } - - /// - /// The visual mesh box, placed with the SAME transform as the physics - /// polygons above. It is drawn as the object's own rotated box (eight - /// transformed corners, twelve edges) rather than as a world-axis-aligned - /// box, so a rotated object's magenta lines still bound its actual visual. - /// - private static void DrawVisualBounds( - DebugLineRenderer lines, - in ShadowEntry shadow, - GfxObjVisualBounds? visual, - LineBudget budget) - { - if (visual is null || budget.Exhausted) return; - - Vector3 min = visual.Min; - Vector3 max = visual.Max; - Span corners = - [ - ToWorld(new Vector3(min.X, min.Y, min.Z), in shadow), - ToWorld(new Vector3(max.X, min.Y, min.Z), in shadow), - ToWorld(new Vector3(max.X, max.Y, min.Z), in shadow), - ToWorld(new Vector3(min.X, max.Y, min.Z), in shadow), - ToWorld(new Vector3(min.X, min.Y, max.Z), in shadow), - ToWorld(new Vector3(max.X, min.Y, max.Z), in shadow), - ToWorld(new Vector3(max.X, max.Y, max.Z), in shadow), - ToWorld(new Vector3(min.X, max.Y, max.Z), in shadow), - ]; - - ReadOnlySpan edges = - [ - 0, 1, 1, 2, 2, 3, 3, 0, - 4, 5, 5, 6, 6, 7, 7, 4, - 0, 4, 1, 5, 2, 6, 3, 7, - ]; - - for (int e = 0; e < edges.Length; e += 2) - { - if (!budget.TryAdd()) return; - lines.AddLine(corners[edges[e]], corners[edges[e + 1]], VisualColor); - } - } - - /// - /// The registered broadphase shape — what the pre-#337 overlay showed, and - /// what the collision system's reach filter measures against. Kept so this - /// overlay is a superset of the one it replaces. - /// - private static void DrawBroadphaseProxy( - DebugLineRenderer lines, - in ShadowEntry shadow, - LineBudget budget) - { - // AddCylinder emits a fixed 36 lines. Reserve them together so a - // partial ring cannot be drawn. - if (!budget.TryAdd(36)) return; - - if (shadow.CollisionType == ShadowCollisionType.Cylinder) - { - float height = shadow.CylHeight > 0f ? shadow.CylHeight : shadow.Radius * 2f; - lines.AddCylinder(shadow.Position, shadow.Radius, height, BroadphaseColor); - return; - } - - lines.AddCylinder( - shadow.Position - new Vector3(0f, 0f, shadow.Radius), - shadow.Radius, - shadow.Radius * 2f, - BroadphaseColor); - } - - /// - /// The terrain surface under the player, sampled through the physics - /// engine's own height resolver — the same numbers the resolver grounds - /// against, not a re-derivation. Drawn as a grid rather than as the single - /// containing triangle so the slope around the player reads at a glance. - /// - private void DrawTerrainGrid(DebugLineRenderer lines, Vector3 centre, LineBudget budget) - { - int steps = (int)(TerrainGridHalfWidth * 2f / TerrainGridStep); - float originX = centre.X - TerrainGridHalfWidth; - float originY = centre.Y - TerrainGridHalfWidth; - - for (int ix = 0; ix <= steps; ix++) - { - for (int iy = 0; iy <= steps; iy++) - { - float x = originX + ix * TerrainGridStep; - float y = originY + iy * TerrainGridStep; - float? z = _physics.SampleTerrainZ(x, y); - if (z is null) continue; - - var here = new Vector3(x, y, z.Value); - - if (ix < steps) - { - float nx = x + TerrainGridStep; - if (_physics.SampleTerrainZ(nx, y) is { } nz) - { - if (!budget.TryAdd()) return; - lines.AddLine(here, new Vector3(nx, y, nz), TerrainColor); - } - } - - if (iy < steps) - { - float ny = y + TerrainGridStep; - if (_physics.SampleTerrainZ(x, ny) is { } nz2) - { - if (!budget.TryAdd()) return; - lines.AddLine(here, new Vector3(x, ny, nz2), TerrainColor); - } - } - } - } - } - - /// - /// The one placement formula, matching the [resolve-bldg] probe's - /// world transform for a shadow part - /// (TransitionTypes.FindObjCollisionsInCell): scale in the part's - /// own frame, then rotate, then translate to the registered position. - /// - private static Vector3 ToWorld(Vector3 local, in ShadowEntry shadow) - => shadow.Position + Vector3.Transform(local * shadow.Scale, shadow.Rotation); - - /// - /// Mutable line counter shared across the draw. A class rather than a - /// struct so the per-shape helpers can be static and still share it - /// without ref-plumbing through every signature. - /// - private sealed class LineBudget(int limit) - { - public int Used { get; private set; } - - public bool Capped { get; private set; } - - public bool Exhausted => Used >= limit; - - public bool TryAdd(int count = 1) - { - if (Used + count > limit) - { - Capped = true; - return false; - } - Used += count; - return true; - } - } -} - -/// What one emitted. -/// is the interesting one: a -/// non-zero count means objects near the player carry no reachable collision -/// polygons at all. -internal readonly record struct CollisionMeshWireframeStats( - int ObjectsConsidered, - int PolygonsDrawn, - int ObjectsWithoutPhysicsGeometry, - int LinesDrawn, - bool Capped); diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs index f329013c..04708dcb 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs @@ -302,37 +302,6 @@ internal sealed class RetailPViewPassExecutor : slice, sliceIndex); - public void EmitOutStageOwner( - WorldEntity entity, - Vector3 sphereCenter, - float sphereRadius, - int sliceIndex, - bool passed) => - _diagnostics.EmitOutStageOwner( - RenderingDiagnostics.ProbeOutStageEnabled, - RenderingDiagnostics.DumpEntitySourceIds, - entity, - sphereCenter, - sphereRadius, - sliceIndex, - passed); - - public void EmitOutStageRouting( - int sliceIndex, - IReadOnlyList entities, - ViewconeCuller viewcone) => - _diagnostics.EmitOutStageRouting( - RenderingDiagnostics.ProbeOutStageEnabled, - sliceIndex, - entities, - viewcone); - - public void EmitPhantomObjects(uint cellId, int survivorCount) => - _diagnostics.EmitPhantomObjects( - RenderingDiagnostics.ProbePhantomEnabled, - cellId, - survivorCount); - public void DrawLandscapeSlice( RetailPViewFrameInput frame, RetailPViewLandscapeSliceContext context) @@ -449,11 +418,6 @@ internal sealed class RetailPViewPassExecutor : _particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds); - _diagnostics.EmitOutStageParticles( - RenderingDiagnostics.ProbeOutStageEnabled, - _particles, - _particleClassifications.Outdoor); - if (!frame.RootCell.IsOutdoorNode && _particleClassifications.Outdoor.Count > 0 && _particles is not null @@ -575,7 +539,6 @@ internal sealed class RetailPViewPassExecutor : RetailPViewFrameInput frame, RetailPViewFrameResult result) => _diagnostics.EmitRetailPViewDiagnostics( - RenderingDiagnostics.ProbeViewerEnabled, RenderingDiagnostics.ProbeVisibilityEnabled, RenderingDiagnostics.ProbeFlapEnabled, result, @@ -584,7 +547,6 @@ internal sealed class RetailPViewPassExecutor : frame.PlayerCellId, frame.CameraWorldPosition, frame.PlayerViewPosition, - frame.CameraView, frame.CameraCellResolution); private void DrawPortalDepthWrite( diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 9b515852..d39df8f8 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -699,15 +699,6 @@ public sealed class RetailPViewRenderer r); if (ownerPass) _lateParticleOwnerScratch.Add(e.Id); - // #131 owner watchlist (throwaway): ACDREAM_DUMP_ENTITY ids - // double as an ENTITY-id watchlist here — one line per watched - // outdoor-static owner per CHANGE of its cone verdict. - passes.EmitOutStageOwner( - e, - c, - r, - probeSliceIndex, - ownerPass); } } foreach (var e in _outsideStageDynamics) @@ -734,10 +725,6 @@ public sealed class RetailPViewRenderer probeSliceIndex, 0); } - passes.EmitOutStageRouting( - probeSliceIndex, - _outsideStageDynamics, - viewcone); _candidateObserver?.ObservePViewBucket( CurrentRenderPViewRoute.LandscapeOutsideDynamic, probeSliceIndex, @@ -1093,9 +1080,6 @@ public sealed class RetailPViewRenderer int survivors = _allCellStatics.Count - survivorsBefore; if (survivors > 0) _cellObjCells.Add(cellId); - - // BR-2 phantom-site probe (T3-updated): post-viewcone survivors. - passes.EmitPhantomObjects(cellId, survivors); } // ONE batched static-object draw for every visible cell (was N per-cell @@ -1210,8 +1194,6 @@ public sealed class RetailPViewRenderer private bool LegacyPartitionDiagnosticsEnabled => _partitionObserver is not null - || AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled - || AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled || AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled || AcDream.App.Streaming.EntityVanishProbe.Enabled; @@ -1380,17 +1362,6 @@ public interface IRetailPViewPassExecutor ClipFrameAssembly clipAssembly, ClipViewSlice slice, int sliceIndex); - void EmitOutStageOwner( - WorldEntity entity, - Vector3 sphereCenter, - float sphereRadius, - int sliceIndex, - bool passed); - void EmitOutStageRouting( - int sliceIndex, - IReadOnlyList entities, - ViewconeCuller viewcone); - void EmitPhantomObjects(uint cellId, int survivorCount); void DrawLandscapeSlice(RetailPViewFrameInput frame, RetailPViewLandscapeSliceContext context); void DrawLandscapeSliceLate(RetailPViewFrameInput frame, RetailPViewLandscapeLateSliceContext context); void ClearInteriorDepth(); diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index 16a9e887..271e00fe 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -993,48 +993,6 @@ public sealed partial class EnvCellRenderer : _lastFrameStats.TrianglesDrawn += (dc.renderData.Batches.Count > 0 ? dc.renderData.Batches[0].IndexCount / 3 : 0) * dc.count; - - // Issue #78 (2026-05-31) [shell] probe (ACDREAM_PROBE_SHELL) — THROWAWAY. - // Per opaque-pass call: totals + per visible (filtered) cell whether it is - // present in the prepared snapshot, and its geometry/flags. Answers why the - // interior walls/ceiling don't appear: NOSNAP / gfx=0 ⇒ no shell geometry - // prepared for the cell; idx>0 + zh>0 ⇒ prepared but missing bindless texture - // (invisible); idx>0 + zh=0 + tr=0 ⇒ opaque geometry drawn (fault is depth/ - // occlusion or the geometry isn't the wall). Opaque pass only (halves noise). - if (renderPass == WbRenderPass.Opaque - && AcDream.Core.Rendering.RenderingDiagnostics.ProbeShellEnabled) - { - var sb = new System.Text.StringBuilder(256); - sb.Append("[shell] filter=").Append(filter?.Count ?? -1) - .Append(" drawCalls=").Append(drawCalls.Count) - .Append(" inst=").Append(allInstances.Count) - .Append(" tris=").Append(_lastFrameStats.TrianglesDrawn); - if (filter != null) - { - foreach (var cellId in filter) - { - if (!snapshot.BatchedByCell.TryGetValue(cellId, out var gfxDict)) - { - sb.Append(" [0x").Append(cellId.ToString("X8")).Append(":NOSNAP]"); - continue; - } - int gfxN = 0, tf = 0, batch = 0, idx = 0, tr = 0, zh = 0; - foreach (var (gfxObjId, transforms) in gfxDict) - { - gfxN++; tf += transforms.Count; - var rd = _meshManager.TryGetRenderData(gfxObjId); - if (rd != null) - foreach (var b in rd.Batches) - { batch++; idx += b.IndexCount; if (b.IsTransparent) tr++; if (!b.TextureSlot.IsAssigned) zh++; } - } - sb.Append(" [0x").Append(cellId.ToString("X8")) - .Append(":gfx=").Append(gfxN).Append(" tf=").Append(tf) - .Append(" batch=").Append(batch).Append(" idx=").Append(idx) - .Append(" tr=").Append(tr).Append(" zh=").Append(zh).Append(']'); - } - } - System.Console.WriteLine(sb.ToString()); - } } } diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index f9fdbdd2..3c1f4bd9 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -780,21 +780,6 @@ public sealed partial class WbDrawDispatcher : IDisposable private readonly HashSet _missRequested = new(); private readonly HashSet _missLogged = new(); - // #119 decisive probe (2026-06-11): ACDREAM_DUMP_ENTITY one-shot entity - // dump. Keyed by entity Id; the stored signature re-emits the header line - // whenever (MeshRefs count, cache batch count, zero-translation count, - // culled) changes — e.g. the Tier-1 populate landing one frame after the - // first slow-path draw. The full per-part listing prints only on first - // sight. Inert (one Count==0 check per new entity) when the env var is - // unset. Render-thread only. - private readonly Dictionary _entityDumpSig = new(); - - // Rate limiter for [dump-entity] WALK-REJECT lines: a rejected entity - // re-tests every frame; emit the first rejection per entity then every - // 300th (~5 s at 60 fps). Static because WalkEntitiesInto is static; - // render-thread only like the walk itself. - private static readonly Dictionary _walkRejectCounts = new(); - // CPU + GPU timing for [WB-DIAG] under ACDREAM_WB_DIAG=1. The GPU samples // are written by the RHI arm's SampleRhiTimers (WbDrawDispatcher.Rhi.cs) // from the device's own timer pool; the raw-GL query-object ring that used @@ -1200,7 +1185,6 @@ public sealed partial class WbDrawDispatcher : IDisposable if (!cellInVis) { if (shellScoped) result.BuildingShellAnchorReject++; - MaybeEmitWalkRejectDump(entity, "visibleCellIds-miss"); if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled && indoorProbeState!.ShouldEmit(cellProbeId)) { @@ -1226,7 +1210,6 @@ public sealed partial class WbDrawDispatcher : IDisposable if (!aabbVisible) { - MaybeEmitWalkRejectDump(entity, "frustum"); if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled && indoorProbeState!.ShouldEmit(cellProbeId)) { @@ -1324,117 +1307,6 @@ public sealed partial class WbDrawDispatcher : IDisposable } } - /// - /// #119 decisive probe: rate-limited [dump-entity] WALK-REJECT line - /// for an ACDREAM_DUMP_ENTITY-targeted entity that the walk filtered - /// out (visibleCellIds gate / per-entity frustum). Absence of any DRAW dump - /// plus presence of these lines attributes "entity exists but never reaches - /// the draw loop" to the specific gate. Inert when the target set is empty. - /// - private static void MaybeEmitWalkRejectDump(WorldEntity entity, string reason) - { - var targets = RenderingDiagnostics.DumpEntitySourceIds; - if (targets.Count == 0 || !targets.Contains(entity.SourceGfxObjOrSetupId)) return; - _walkRejectCounts.TryGetValue(entity.Id, out int n); - _walkRejectCounts[entity.Id] = n + 1; - if (n % 300 != 0) return; - Console.WriteLine( - $"[dump-entity] WALK-REJECT id=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} " + - $"reason={reason} parentCell=0x{(entity.ParentCellId ?? 0u):X8} " + - $"pos=({entity.Position.X:F2},{entity.Position.Y:F2},{entity.Position.Z:F2}) n={n + 1}"); - } - - /// - /// #119 decisive probe: per-entity state dump at draw time for - /// ACDREAM_DUMP_ENTITY-targeted entities. First sight prints a - /// header + every MeshRef's GfxObj id, part-transform translation, and - /// loaded flag; afterwards a compact header re-emits only when the - /// (meshRefs, cacheBatches, zeroTranslations, culled) signature changes. - /// Discriminates H-A (hydration-time MeshRef corruption: translations - /// collapsed to ~zero / missing parts) from H-B (Tier-1 cache holding a - /// partial or stale batch set) from H-C (both healthy ⇒ draw-side compose). - /// - private void MaybeEmitEntityDump( - in RenderInstanceCandidate entity, - uint landblockId, - bool culled, - IReadOnlyList tuples) - { - var targets = RenderingDiagnostics.DumpEntitySourceIds; - if (targets.Count == 0 || !targets.Contains(entity.SourceId)) - return; - - int zeroT = 0; - int refsCount = 0; - float tzMin = float.MaxValue, tzMax = float.MinValue; - for (int i = 0; i < tuples.Count; i++) - { - RenderInstanceTuple tuple = tuples[i]; - if (tuple.Candidate.LocalEntityId != entity.LocalEntityId - || tuple.Candidate.TupleLandblockId - != entity.TupleLandblockId) - { - continue; - } - - refsCount++; - Vector3 t = tuple.MeshRef.PartTransform.Translation; - if (t.LengthSquared() < 1e-9f) zeroT++; - if (t.Z < tzMin) tzMin = t.Z; - if (t.Z > tzMax) tzMax = t.Z; - } - - int cacheBatches = -1; - int restZero = 0; - float rzMin = float.MaxValue, rzMax = float.MinValue; - if (_cache.TryGet(entity.Id, landblockId, out var cacheEntry)) - { - cacheBatches = cacheEntry!.Batches.Length; - foreach (var b in cacheEntry.Batches) - { - var t = b.RestPose.Translation; - if (t.LengthSquared() < 1e-9f) restZero++; - if (t.Z < rzMin) rzMin = t.Z; - if (t.Z > rzMax) rzMax = t.Z; - } - } - - var sig = (refsCount, cacheBatches, zeroT, culled); - bool first = !_entityDumpSig.TryGetValue(entity.Id, out var prev); - if (!first && prev == sig) return; - _entityDumpSig[entity.Id] = sig; - - string cacheStr = cacheBatches < 0 - ? (_tier1CacheDisabled ? "disabled" : "miss") - : $"hit:{cacheBatches} restZero={restZero} restZ=[{rzMin:F2}..{rzMax:F2}]"; - Console.WriteLine( - $"[dump-entity] DRAW{(first ? "" : "-CHANGED")} id=0x{entity.Id:X8} src=0x{entity.SourceId:X8} " + - $"lb=0x{landblockId:X8} cell=0x{entity.ParentCellId:X8} " + - $"pos=({entity.Position.X:F2},{entity.Position.Y:F2},{entity.Position.Z:F2}) scale={entity.Scale:F2} " + - $"meshRefs={refsCount} tZero={zeroT} tZ=[{tzMin:F2}..{tzMax:F2}] cache={cacheStr} culled={culled}"); - - if (first) - { - for (int i = 0; i < tuples.Count; i++) - { - RenderInstanceTuple tuple = tuples[i]; - if (tuple.Candidate.LocalEntityId - != entity.LocalEntityId - || tuple.Candidate.TupleLandblockId - != entity.TupleLandblockId) - { - continue; - } - - MeshRef mr = tuple.MeshRef; - var t = mr.PartTransform.Translation; - bool loaded = _meshAdapter.TryGetRenderData(mr.GfxObjId) is not null; - Console.WriteLine( - $"[dump-entity] part[{tuple.MeshRefIndex:D2}] gfx=0x{mr.GfxObjId:X8} t=({t.X:F3},{t.Y:F3},{t.Z:F3}) loaded={loaded}"); - } - } - } - public void Draw( ICamera camera, IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, @@ -1651,15 +1523,6 @@ public sealed partial class WbDrawDispatcher : IDisposable ? new Vector2(lighting.Luminosity, lighting.Diffuse) : new Vector2(0f, 1f); - // #119 decisive probe: one-shot dump (+ change re-emission) for - // ACDREAM_DUMP_ENTITY-targeted entities. Before the culled-continue - // so a routed-out entity still reports its state. - MaybeEmitEntityDump( - in entity, - cacheLb, - _currentEntityCulled, - _candidateTupleScratch); - // #176 seam-draw probe: any entity parented to a target cell reports // its position + light set (a floor-coincident static/plate would be // the z-fight's second draw; the player entity is the positive diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index e8f7351c..0a866362 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -589,11 +589,6 @@ public sealed class WbMeshAdapter meshManager.RequeueStagedMeshData(m); meshManager.SetArenaBackpressure(arenaBackpressured); - bool texProbe = AcDream.Core.Rendering.RenderingDiagnostics.ProbeTexFlushEnabled; - var pendingBefore = texProbe - ? meshManager.GetPendingTextureUpdateStats() - : default; - // #105 root cause (2026-06-10): TextureAtlasManager.AddTexture only STAGES // immutable decoded payloads in ManagedGLTextureArray._pendingUpdates — the // actual TexSubImage3D copies + mipmap regeneration happen in @@ -643,9 +638,6 @@ public sealed class WbMeshAdapter LastBufferAllocationBytes = uploadBudget.BufferAllocationBytes; LastBufferCopyBytes = uploadBudget.BufferCopyBytes; LastNewBufferCount = uploadBudget.NewBufferCount; - - if (texProbe) - EmitTexFlushProbe(pendingBefore); } private static void RejectUnsupportedHead( @@ -663,32 +655,6 @@ public sealed class WbMeshAdapter meshManager.RejectUnsupportedStagedUpload(rejected, error); } - // #105 apparatus state — see RenderingDiagnostics.ProbeTexFlushEnabled. - private int _lastTexFlushBefore = -1; - private int _texFlushHeartbeat; - - /// - /// #105 apparatus: one [tex-flush] line on change of the staged-texture - /// pending picture (plus a ~10 s heartbeat while anything is stuck). A healthy - /// frame ends with after=0; before==after>0 persisting at - /// standstill is the white-walls mechanism live (staged uploads never applied). - /// - private void EmitTexFlushProbe((int PendingUpdates, int ArraysWithPending, int TotalArrays) before) - { - var after = _meshManager!.GetPendingTextureUpdateStats(); - bool changed = before.PendingUpdates != _lastTexFlushBefore; - bool flushed = after.PendingUpdates != before.PendingUpdates; - bool heartbeat = after.PendingUpdates > 0 && ++_texFlushHeartbeat >= 600; - if (!changed && !flushed && !heartbeat) return; - - _texFlushHeartbeat = 0; - _lastTexFlushBefore = before.PendingUpdates; - Console.WriteLine( - $"[tex-flush] before={before.PendingUpdates} after={after.PendingUpdates}" + - $" arrays={after.ArraysWithPending}/{after.TotalArrays}" + - $" (arraysBefore={before.ArraysWithPending})"); - } - /// public void Dispose() { diff --git a/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs b/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs index 1ae0191b..dede5851 100644 --- a/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs +++ b/src/AcDream.App/Rendering/WorldRenderDiagnostics.cs @@ -51,15 +51,11 @@ internal sealed class WorldRenderDiagnostics { private readonly IRenderGlStateReader _gl; private readonly IRenderFrameDiagnosticLog _log; - private readonly HashSet _lastViewerFloodCells = []; - private readonly HashSet _outStageUnmatched = []; - private readonly HashSet _outStageMatched = []; private readonly Stopwatch _terrainStopwatch = new(); private readonly RollingTimingSampleWindow _terrainSamples = new(256); private string? _lastRenderSignature; private int _renderSignatureFrame; private int _renderSignatureStableFrames; - private string? _lastViewerSignature; private string? _lastGlStateSignature; private long _glStateFrame; private long _glStateStableFrames; @@ -68,10 +64,6 @@ internal sealed class WorldRenderDiagnostics private long _postWorldGlStateStableFrames; private string? _lastScissorSignature; private long _scissorSequence; - private string? _lastOutStageSignature; - private string? _lastOutStageRoutingSignature; - private readonly Dictionary _outStageOwnerVerdicts = []; - private readonly Dictionary _phantomObjectSignatures = []; private string? _lastClipRouteSignature; private long _clipRouteSequence; private readonly List _clipRouteCellKeys = []; @@ -258,75 +250,6 @@ internal sealed class WorldRenderDiagnostics _log.WriteLine($"[clip-route] n={_clipRouteSequence} {signature}"); } - public void EmitOutStageOwner( - bool enabled, - IReadOnlySet watchedEntityIds, - WorldEntity entity, - Vector3 sphereCenter, - float sphereRadius, - int sliceIndex, - bool passed) - { - if (!enabled - || !watchedEntityIds.Contains(entity.Id) - || (_outStageOwnerVerdicts.TryGetValue(entity.Id, out bool previous) - && previous == passed)) - { - return; - } - - _outStageOwnerVerdicts[entity.Id] = passed; - _log.WriteLine(FormattableString.Invariant( - $"[outstage-own] id=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} pos=({entity.Position.X:F1},{entity.Position.Y:F1},{entity.Position.Z:F1}) c=({sphereCenter.X:F1},{sphereCenter.Y:F1},{sphereCenter.Z:F1}) r={sphereRadius:F1} slice={sliceIndex} {(passed ? "PASS" : "CULL")}")); - } - - public void EmitOutStageRouting( - bool enabled, - int sliceIndex, - IReadOnlyList entities, - ViewconeCuller viewcone) - { - if (!enabled) - return; - - var text = new StringBuilder(192); - text.Append("slice=").Append(sliceIndex) - .Append(" outStage=").Append(entities.Count).Append(" ["); - for (int i = 0; i < entities.Count; i++) - { - WorldEntity entity = entities[i]; - EntitySphere(entity, out Vector3 center, out float radius); - bool passed = viewcone.SphereVisibleInOutsideSlice( - sliceIndex, - center, - radius); - if (i > 0) - text.Append(' '); - text.Append(FormattableString.Invariant( - $"0x{(entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id):X8}(s{entity.SourceGfxObjOrSetupId:X8}):{(passed ? "PASS" : "CULL")}:r={radius:F1}")); - } - text.Append(']'); - string signature = text.ToString(); - if (signature == _lastOutStageRoutingSignature) - return; - _lastOutStageRoutingSignature = signature; - _log.WriteLine("[outstage] " + signature); - } - - public void EmitPhantomObjects(bool enabled, uint cellId, int survivorCount) - { - if (!enabled - || (_phantomObjectSignatures.TryGetValue(cellId, out int previous) - && previous == survivorCount)) - { - return; - } - - _phantomObjectSignatures[cellId] = survivorCount; - _log.WriteLine( - $"[phantom-objs] cell=0x{cellId:X8} entities={survivorCount} (drawn unclipped, no viewcone)"); - } - public void EmitSeamMask( bool enabled, IReadOnlySet targetCells, @@ -374,57 +297,7 @@ internal sealed class WorldRenderDiagnostics $"[pv-input] outRoot={root} flood={portalFrame.OrderedVisibleCells.Count} eye=({eye.X:F6},{eye.Y:F6},{eye.Z:F6}) player=({player.X:F6},{player.Y:F6},{player.Z:F6}) rawPlayer=({rawPlayer.X:F6},{rawPlayer.Y:F6},{rawPlayer.Z:F6}) yaw={yaw:F8} {terrain} vp=[{vp.M11:F6} {vp.M13:F6} {vp.M22:F6} {vp.M31:F6} {vp.M33:F6} {vp.M41:F6} {vp.M42:F6} {vp.M43:F6}]")); } - public void EmitOutStageParticles( - bool enabled, - ParticleSystem? particles, - IReadOnlySet ownerIds) - { - if (!enabled || particles is null) - return; - - int matched = 0; - int attached = 0; - int unattached = 0; - _outStageUnmatched.Clear(); - _outStageMatched.Clear(); - foreach (var (emitter, _) in particles.EnumerateLive()) - { - if (emitter.AttachedObjectId == 0) - { - unattached++; - continue; - } - - attached++; - if (ownerIds.Contains(emitter.AttachedObjectId)) - { - matched++; - if (_outStageMatched.Count < 48) - _outStageMatched.Add(emitter.AttachedObjectId); - } - else if (_outStageUnmatched.Count < 12) - { - _outStageUnmatched.Add(emitter.AttachedObjectId); - } - } - - var unmatched = new StringBuilder(96); - foreach (uint id in _outStageUnmatched) - unmatched.Append(FormattableString.Invariant($" 0x{id:X8}")); - var matchedIds = new StringBuilder(192); - foreach (uint id in _outStageMatched) - matchedIds.Append(FormattableString.Invariant($" 0x{id:X8}")); - string signature = FormattableString.Invariant( - $"ids={ownerIds.Count} attachedEmitters={attached} matched={matched} unattached={unattached} matchedIds=[{matchedIds}] unmatchedIds=[{unmatched}]"); - if (signature == _lastOutStageSignature) - return; - - _lastOutStageSignature = signature; - _log.WriteLine("[outstage-pt] " + signature); - } - public void EmitRetailPViewDiagnostics( - bool viewerEnabled, bool visibilityEnabled, bool flapEnabled, RetailPViewFrameResult result, @@ -433,22 +306,8 @@ internal sealed class WorldRenderDiagnostics uint playerCellId, Vector3 cameraPosition, Vector3 playerPosition, - Matrix4x4 cameraView, CameraCellResolution cameraCellResolution) { - if (viewerEnabled) - { - string signature = FormattableString.Invariant( - $"root=0x{clipRoot.CellId:X8}{(clipRoot.IsOutdoorNode ? "(OUT)" : string.Empty)} flood={result.PortalFrame.OrderedVisibleCells.Count} outPolys={result.PortalFrame.OutsideView.Polygons.Count} pCell=0x{playerCellId:X8}"); - if (signature != _lastViewerSignature) - { - _lastViewerSignature = signature; - _log.WriteLine(FormattableString.Invariant( - $"[viewer] {signature} eye=({cameraPosition.X:F3},{cameraPosition.Y:F3},{cameraPosition.Z:F3}) fwd=({-cameraView.M13:F4},{-cameraView.M23:F4},{-cameraView.M33:F4}) viewerCell=0x{viewerCellId:X8}")); - EmitViewerDiff(result.PortalFrame.OrderedVisibleCells); - } - } - if (visibilityEnabled) { AcDream.Core.Rendering.RenderingDiagnostics.EmitVis( @@ -619,49 +478,6 @@ internal sealed class WorldRenderDiagnostics + $"stencil={(state.Stencil ? 1 : 0)} " + $"clip=0x{state.ClipBits:X2} err=0x{state.Error:X}"; - private void EmitViewerDiff(IReadOnlyList current) - { - var text = new StringBuilder(96); - text.Append("[viewer-diff] added=["); - bool first = true; - foreach (uint cell in current) - { - if (_lastViewerFloodCells.Contains(cell)) - continue; - if (!first) - text.Append(','); - text.Append("0x").Append(cell.ToString("X8")); - first = false; - } - - text.Append("] removed=["); - first = true; - foreach (uint cell in _lastViewerFloodCells) - { - bool present = false; - for (int index = 0; index < current.Count; index++) - { - if (current[index] == cell) - { - present = true; - break; - } - } - if (present) - continue; - if (!first) - text.Append(','); - text.Append("0x").Append(cell.ToString("X8")); - first = false; - } - text.Append(']'); - _log.WriteLine(text.ToString()); - - _lastViewerFloodCells.Clear(); - foreach (uint cell in current) - _lastViewerFloodCells.Add(cell); - } - private static string FormatVector(Vector3 value) { static float Quantize(float component) => MathF.Round(component * 20f) / 20f; @@ -748,15 +564,4 @@ internal sealed class WorldRenderDiagnostics .Append(" live=").Append(partition.Dynamics.Count) .ToString(); } - - private static void EntitySphere( - WorldEntity entity, - out Vector3 center, - out float radius) - { - if (entity.AabbDirty) - entity.RefreshAabb(); - center = (entity.AabbMin + entity.AabbMax) * 0.5f; - radius = (entity.AabbMax - entity.AabbMin).Length() * 0.5f; - } } diff --git a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs index 7fdc66ae..bbfac8d7 100644 --- a/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs +++ b/src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs @@ -513,17 +513,6 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation // (150..1500 m) fog and hid the fact that the sky pass was being // fogged at all (see SkyRenderer / sky.frag). _lightingUbo?.Upload(ubo); - - RenderingDiagnostics.EmitLight( - insideCell: roots.PlayerInsideCell, - ambientR: _lighting.CurrentAmbient.AmbientColor.X, - ambientG: _lighting.CurrentAmbient.AmbientColor.Y, - ambientB: _lighting.CurrentAmbient.AmbientColor.Z, - sunIntensity: _lighting.Sun?.Intensity ?? 0f, - registeredLights: _lighting.RegisteredCount, - activeLights: (int)ubo.CellAmbient.W, - playerCellId: roots.PlayerRoot?.CellId ?? 0u, - lights: _lighting); } public void ObserveDrawableCells(IReadOnlySet drawableCells) diff --git a/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs b/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs index e3dbb540..e36f8a0c 100644 --- a/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs +++ b/src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs @@ -64,10 +64,6 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics private readonly DebugVmRenderFactsPublisher _debugVm; private readonly bool _debugVmConsumerActive; private int _debugDrawLogCount; - // #337 (TEMPORARY): built on first use so the ordinary overlay path and - // every headless/no-window host pay nothing for it. - private CollisionMeshWireframe? _meshWireframe; - private CollisionMeshWireframeStats _lastMeshStats = new(-1, -1, -1, -1, false); public WorldSceneDiagnosticsController( WorldRenderDiagnostics diagnostics, @@ -219,18 +215,6 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics if (!_state.CollisionWireframesVisible || _lines is null) return; - // #337 (2026-08-06 — TEMPORARY): ACDREAM_WIRE_MESH=1 replaces the - // broadphase-proxy overlay below with the objects' real physics-BSP - // polygon edges beside their visual mesh boxes and the terrain surface - // — see CollisionMeshWireframe for why the proxy cannot answer the - // question this mode exists for. Default off; F2 keeps its old - // behaviour otherwise. - if (RenderingDiagnostics.CollisionMeshWireframeEnabled) - { - DrawCollisionMesh(in camera); - return; - } - _lines.Begin(); int drawn = 0; foreach (ShadowEntry shadow in _physics.ShadowObjects.AllEntriesForDebug()) @@ -271,50 +255,6 @@ internal sealed class WorldSceneDiagnosticsController : IWorldSceneDiagnostics _lines.Flush(camera.Camera.View, camera.Projection); } - /// - /// #337 (2026-08-06 — TEMPORARY, strip with the probe family). Centres on - /// the player when there is one, else on the camera, so the fly-camera - /// mode can inspect geometry too. - /// - private void DrawCollisionMesh(in WorldCameraFrame camera) - { - Vector3 centre = _mode.IsPlayerMode && _player.Controller is { } controller - ? controller.Position - : camera.Position; - - _meshWireframe ??= new CollisionMeshWireframe(_physics); - - _lines!.Begin(); - CollisionMeshWireframeStats stats = _meshWireframe.Draw(_lines, centre); - - if (_mode.IsPlayerMode && _player.Controller is { } player) - { - _lines.AddCylinder( - player.Position, - DebugVmRenderFactsPublisher.PlayerCollisionRadius, - 1.8f, - new Vector3(1f, 0f, 0f)); - } - - _lines.Flush(camera.Camera.View, camera.Projection); - - // A capped frame is showing PARTIAL geometry, which would otherwise be - // indistinguishable from an object that has none — exactly the - // misreading this overlay exists to prevent. Say so, throttled, rather - // than letting the picture lie. - if (stats != _lastMeshStats) - { - _lastMeshStats = stats; - Console.WriteLine(string.Format( - System.Globalization.CultureInfo.InvariantCulture, - "[wire-mesh] centre=({0:F2},{1:F2},{2:F2}) objects={3} polys={4} " + - "noPhysicsGeometry={5} lines={6} capped={7}", - centre.X, centre.Y, centre.Z, - stats.ObjectsConsidered, stats.PolygonsDrawn, - stats.ObjectsWithoutPhysicsGeometry, stats.LinesDrawn, stats.Capped)); - } - } - private void LogNearbyCollisionObjects(Vector3 playerPosition, int drawn) { if (_debugDrawLogCount >= 5) diff --git a/src/AcDream.App/Streaming/LandblockBuildFactory.cs b/src/AcDream.App/Streaming/LandblockBuildFactory.cs index 5e1e04cc..5654ebb8 100644 --- a/src/AcDream.App/Streaming/LandblockBuildFactory.cs +++ b/src/AcDream.App/Streaming/LandblockBuildFactory.cs @@ -557,15 +557,6 @@ public sealed class LandblockBuildFactory // Phase 2d: static objects inside the EnvCell. foreach (var stab in envCell.StaticObjects) { - // #119 decisive probe: HYDRATE-side dump for ACDREAM_DUMP_ENTITY- - // targeted stabs. This is the MOMENT MeshRefs are constructed — - // a degraded dat read here (setup null / placement frames short / - // part GfxObj null) permanently corrupts the entity (H-A), and - // nothing downstream ever rebuilds it. Inert when the set is empty. - bool dumpStab = AcDream.Core.Rendering.RenderingDiagnostics - .DumpEntitySourceIds.Contains(stab.Id); - int dumpSetupParts = -1, dumpPlacementFrames = -1, dumpFlattened = -1, dumpDropped = 0; - // #136: skip an EDITOR-ONLY placement marker. Such a dat object degrades to // nothing (GfxObj id 0) at any runtime distance, so retail's distance-based // degrade (CPhysicsPart::UpdateViewerDistance) never draws it — only the @@ -596,10 +587,6 @@ public sealed class LandblockBuildFactory if (pb is not null) interiorBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value); meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity)); } - else if (dumpStab) - { - Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} GFXOBJ-NULL -> entity dropped"); - } } else if ((stab.Id & 0xFF000000u) == 0x02000000u) { @@ -608,12 +595,6 @@ public sealed class LandblockBuildFactory { stabLightCount = setup.Lights.Count; var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); - if (dumpStab) - { - dumpSetupParts = setup.Parts.Count; - dumpPlacementFrames = setup.PlacementFrames.Count; - dumpFlattened = flat.Count; - } foreach (var mr in flat) { // #136: skip an editor-only marker PART (retail hides it at runtime @@ -625,9 +606,6 @@ public sealed class LandblockBuildFactory var gfx = _dats.Get(mr.GfxObjId); if (gfx is null) { - dumpDropped++; - if (dumpStab) - Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} part gfx=0x{mr.GfxObjId:X8} GFXOBJ-NULL -> part dropped"); continue; } var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx); @@ -635,20 +613,12 @@ public sealed class LandblockBuildFactory meshRefs.Add(mr); } } - else if (dumpStab) - { - Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} SETUP-NULL -> entity dropped"); - } } if (!AcDream.Core.Meshing.EntityHydrationRules.ShouldKeepEntity(meshRefs.Count, stabLightCount)) { - if (dumpStab) - Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} meshRefs=0 lights=0 -> entity dropped"); continue; } - if (meshRefs.Count == 0 && dumpStab) - Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} meshRefs=0 lights={stabLightCount} -> KEPT as mesh-less light carrier"); // Stabs inside EnvCells are already in landblock-local coordinates // (same space as LandBlockInfo.Objects stabs). Adding cellOrigin would @@ -671,20 +641,6 @@ public sealed class LandblockBuildFactory if (interiorBounds.TryGet(out var ibMin, out var ibMax)) hydrated.SetLocalBounds(ibMin, ibMax); - if (dumpStab) - { - Console.WriteLine( - $"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} entId=0x{hydrated.Id:X8} " + - $"setupParts={dumpSetupParts} placementFrames={dumpPlacementFrames} flattened={dumpFlattened} " + - $"built={meshRefs.Count} dropped={dumpDropped} " + - $"pos=({worldPos.X:F2},{worldPos.Y:F2},{worldPos.Z:F2})"); - for (int i = 0; i < meshRefs.Count; i++) - { - var t = meshRefs[i].PartTransform.Translation; - Console.WriteLine($"[dump-entity] hyd-part[{i:D2}] gfx=0x{meshRefs[i].GfxObjId:X8} t=({t.X:F3},{t.Y:F3},{t.Z:F3})"); - } - } - result.Add(hydrated); } } diff --git a/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs b/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs index d1bdf41f..b9e958df 100644 --- a/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs +++ b/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs @@ -34,12 +34,6 @@ public sealed class MotionTableDispatchSink : IInterpretedMotionSink public bool ApplyMotion(uint motion, float speed) { uint result = _sequencer.PerformMovement(MotionTableMovement.Interpreted(motion, speed)); - // Bug A probe ([remote-landing-after], ACDREAM_PROBE_REMOTE_LANDING): - // the MotionTableManagerError code is discarded by this bool return, - // so hand it to the diagnostic latch before it is lost. Self-guarded - // — one flag test when the probe is off, no behaviour change either - // way. TEMPORARY, strips with the rest of the probe family. - PhysicsDiagnostics.RecordRemoteLandingDispatch(motion, result); return result == MotionTableManagerError.Success; } diff --git a/src/AcDream.Core/Physics/Motion/StickyManager.cs b/src/AcDream.Core/Physics/Motion/StickyManager.cs index 1ca0308b..d0794f8f 100644 --- a/src/AcDream.Core/Physics/Motion/StickyManager.cs +++ b/src/AcDream.Core/Physics/Motion/StickyManager.cs @@ -82,10 +82,6 @@ public sealed class StickyManager if (TargetId == 0) return; - if (PhysicsDiagnostics.ProbeStickyEnabled) - Console.WriteLine(FormattableString.Invariant( - $"[sticky] guid=0x{_host.Id:X8} UNSTICK target=0x{TargetId:X8}")); - TargetId = 0; Initialized = false; _host.ClearTarget(); @@ -125,10 +121,6 @@ public sealed class StickyManager Initialized = false; StickyTimeoutTime = _host.CurTime + StickyTime; - if (PhysicsDiagnostics.ProbeStickyEnabled) - Console.WriteLine(FormattableString.Invariant( - $"[sticky] guid=0x{_host.Id:X8} STICK target=0x{objectId:X8} tgtR={targetRadius:F2} ownR={_host.Radius:F2} lease={StickyTime:F1}s")); - // set_target(context_id=0, objectId, radius=0.5, quantum=0.5). _host.SetTarget(0, objectId, 0.5f, 0.5); } @@ -149,10 +141,6 @@ public sealed class StickyManager // C0|C3 clear = cur_time > timeout; ACE `>` too), not >=. if (_host.CurTime > StickyTimeoutTime) { - if (PhysicsDiagnostics.ProbeStickyEnabled) - Console.WriteLine(FormattableString.Invariant( - $"[sticky] guid=0x{_host.Id:X8} LEASE-EXPIRE target=0x{TargetId:X8}")); - TargetId = 0; Initialized = false; _host.ClearTarget(); @@ -183,10 +171,6 @@ public sealed class StickyManager if (TargetId != 0) { - if (PhysicsDiagnostics.ProbeStickyEnabled) - Console.WriteLine(FormattableString.Invariant( - $"[sticky] guid=0x{_host.Id:X8} TARGET-{info.Status} teardown target=0x{TargetId:X8}")); - TargetId = 0; Initialized = false; _host.ClearTarget(); @@ -268,9 +252,5 @@ public sealed class StickyManager if (heading < -MoveToMath.Epsilon) heading += 360f; offset.SetHeading(heading); - - if (PhysicsDiagnostics.ProbeStickyEnabled) - Console.WriteLine(FormattableString.Invariant( - $"[sticky] guid=0x{_host.Id:X8} ADJ dist={dist:F3} delta={delta:F3} speed={speed:F1} hdgDelta={heading:F1} live={(target is not null ? 1 : 0)}")); } } diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index af4f3e87..f25afcf9 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -650,23 +650,6 @@ public sealed class PhysicsDataCache $"[cell-cache] envCellId=0x{envCellId:X8} physicsPolyCount={cellStruct.PhysicsPolygons?.Count ?? 0} resolvedCount={resolved.Count} bspTotalLeafPolys={bspTotalLeafPolys} bspUnmatchedIds={bspUnmatchedIds} {bsStr} portalCount={portals.Count} visibleCells={visibleCellIds.Count} cellBspRoot={(cellStruct.CellBSP?.Root is null ? "null" : "ok")} worldOrigin=({worldOrigin.X:F2},{worldOrigin.Y:F2},{worldOrigin.Z:F2}) {portalTargets}")); } - if (PhysicsDiagnostics.ProbeWalkMissEnabled) - { - int walkableCount = 0; - foreach (var entry in WalkMissDiagnostic.EnumerateWalkable( - resolved, PhysicsGlobals.FloorZ)) - walkableCount++; - - Console.Write(System.FormattableString.Invariant( - $"[floor-polys] cellId=0x{envCellId:X8} walkableCount={walkableCount}")); - foreach (var entry in WalkMissDiagnostic.EnumerateWalkable( - resolved, PhysicsGlobals.FloorZ)) - { - Console.Write(System.FormattableString.Invariant( - $" [id=0x{entry.PolyId:X4} nz={entry.NormalZ:F3} bbox=({entry.BboxMin.X:F2},{entry.BboxMin.Y:F2})..({entry.BboxMax.X:F2},{entry.BboxMax.Y:F2}) planeZ@center={entry.PlaneZAtBboxCenter:F3}]")); - } - Console.WriteLine(); - } } /// diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index 57843b0b..0f3f0102 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -180,60 +180,6 @@ public static class PhysicsDiagnostics public static bool ProbeCellSetEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELLSET") == "1"; - /// - /// R5-V3 #171 residuals (2026-07-04) — sticky-melee timeline probe. - /// One [sticky] line per StickyManager lifecycle event (STICK / - /// UNSTICK / LEASE-EXPIRE / TARGET-status teardown) and per armed - /// AdjustOffset tick (guid, signed gap distance, applied delta, - /// heading delta), plus [sticky-snap-skip] lines at the NPC - /// UpdatePosition handler when a server hard-snap is suppressed because - /// the entity is stuck. Heavy while a pack is stuck (~60 Hz × stuck - /// count); capture-session only. All lines carry the guid - /// (feedback_probe_identity_attribution). - /// - public static bool ProbeStickyEnabled { get; set; } - = Environment.GetEnvironmentVariable("ACDREAM_PROBE_STICKY") == "1"; - - /// - /// Bug A investigation (2026-08-04, live route 4a test — see - /// docs/ISSUES.md #32 and - /// docs/research/2026-08-04-remote-landing-investigation.md): a - /// PLAYER remote sometimes stays in the falling animation after landing, - /// then snaps to the grounded pose after a delay. Three hypotheses were - /// identified, with non-overlapping fixes, so this probe captures the - /// state needed to discriminate them at BOTH remote landing-detection - /// sites: the UpdatePosition landing block in - /// LiveEntityNetworkUpdateController (site=controller) and - /// the per-tick VectorUpdate landing branch in - /// RuntimeRemotePhysicsUpdater (site=per-tick). - /// - /// - /// When true, emits one [remote-landing] line per landing edge via - /// , capturing: the airborne flag on entry, - /// whether the Gravity state bit is still set (the - /// MotionInterpreter.HitGround gate at - /// MotionInterpreter.cs:~2435 no-ops silently when it is NOT — - /// hypothesis 1), the Contact/OnWalkable transient bits, whether a - /// DefaultSink is bound (hypothesis 2 — nothing to dispatch - /// through), the per-tick site's resolveResult.IsOnGround (n/a at - /// the controller site, which has no resolver call), and the - /// sequencer's current style/motion id (hypothesis 3 — the sequencer - /// disagrees with what the re-apply should produce). A companion - /// [remote-landing-gate] line fires via - /// whenever a landing site is - /// reached but Gravity is already clear, so the HitGround call about to - /// happen is a silent no-op — the single most valuable signal for - /// hypothesis 1. - /// - /// - /// - /// Initial state from ACDREAM_PROBE_REMOTE_LANDING=1. TEMPORARY — - /// strip once the discriminating live-test capture has landed. - /// - /// - public static bool ProbeRemoteLandingEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_REMOTE_LANDING") == "1"; - /// /// C4 route 4b-3 (2026-08-04) live-execution proof (process rule 5): one /// [remote-teleport] line per routed teleport arm @@ -267,143 +213,6 @@ public static class PhysicsDiagnostics $"[remote-teleport] guid=0x{guid:X8} cause={cause} hookRan={hookRan} placement={placementStatus}")); } - /// - /// Emit one [remote-landing] line for a remote landing-detection - /// edge. Caller MUST guard with - /// if (!ProbeRemoteLandingEnabled) return; before calling. - /// is at the - /// controller site (no per-frame resolver call at that edge). - /// - public static void LogRemoteLanding( - string site, - uint guid, - bool airborneBefore, - bool gravitySet, - bool contact, - bool onWalkable, - bool hasDefaultSink, - bool? resolveIsOnGround, - uint sequencerStyle, - uint sequencerMotion) - { - var ci = System.Globalization.CultureInfo.InvariantCulture; - string onGroundText = resolveIsOnGround.HasValue - ? resolveIsOnGround.Value.ToString() - : "n/a"; - Console.WriteLine(string.Format(ci, - "[remote-landing] site={0} guid=0x{1:X8} t={2} airborneBefore={3} " + - "gravitySet={4} contact={5} onWalkable={6} hasDefaultSink={7} " + - "resolveIsOnGround={8} seqStyle=0x{9:X8} seqMotion=0x{10:X8}", - site, guid, Environment.TickCount64, airborneBefore, - gravitySet, contact, onWalkable, hasDefaultSink, onGroundText, - sequencerStyle, sequencerMotion)); - } - - /// - /// Emit one [remote-landing-gate] line when a landing edge is - /// reached but the Gravity state bit is already clear, so the imminent - /// MotionInterpreter.HitGround call will silently no-op (the - /// gate at MotionInterpreter.cs:~2435) — hypothesis 1 for Bug A. - /// Caller MUST guard with - /// if (!ProbeRemoteLandingEnabled) return; before calling. - /// - public static void LogRemoteLandingGateNoOp(string site, uint guid) - { - Console.WriteLine(System.FormattableString.Invariant( - $"[remote-landing-gate] site={site} guid=0x{guid:X8} t={Environment.TickCount64} NOOP gravityAlreadyClear=true")); - } - - // ── [remote-landing-after] — the OUTCOME half of the Bug A probe ────── - // - // docs/research/2026-08-04-bug-a-h3-scheduler-diagnosis.md §6.1: the - // [remote-landing] line above reads state immediately BEFORE - // MovementManager.HitGround, so it cannot separate (a) the edge never - // firing, from (b) HitGround firing and something re-asserting Falling, - // from (c) the motion-table sink refusing the cycle. The companion line - // below reads the same entity immediately AFTER the call, at the same - // two sites, and pairs 1:1 with it (same site + guid, next line for - // that guid). - // - // Dispatch capture: MotionTableDispatchSink.ApplyMotion discards the - // MotionTableManagerError code (it returns bool) and HitGround itself - // returns void, so nothing at the call site can observe what the sink - // did. These [ThreadStatic] latches carry it across the synchronous - // HitGround call without changing any signature: the call site calls - // BeginRemoteLandingDispatchCapture() right before HitGround, the sink - // records each ApplyMotion, and LogRemoteLandingAfter reports the count - // plus the LAST ApplyMotion — which for the landing re-apply - // (ApplyInterpretedMovement, MotionInterpreter.cs:2842-2903) is the - // decisive one: either Falling (:2867) or InterpretedState.ForwardCommand - // (:2878). Thread-static because the whole window is synchronous on the - // ticking thread, and headless hosts tick several sessions in parallel. - // - // Every member here is inert unless ProbeRemoteLandingEnabled is true. - // TEMPORARY — strip with the rest of the ACDREAM_PROBE_REMOTE_LANDING - // family once the discriminating live capture has landed. - - [ThreadStatic] private static int _remoteLandingApplyCalls; - [ThreadStatic] private static uint _remoteLandingLastApplyMotion; - [ThreadStatic] private static uint _remoteLandingLastApplyResult; - - /// - /// Arm the per-call sink-dispatch capture read back by - /// . Call immediately before - /// MovementManager.HitGround. No-op unless - /// . - /// - public static void BeginRemoteLandingDispatchCapture() - { - if (!ProbeRemoteLandingEnabled) return; - _remoteLandingApplyCalls = 0; - _remoteLandingLastApplyMotion = 0; - _remoteLandingLastApplyResult = 0; - } - - /// - /// Record one IInterpretedMotionSink.ApplyMotion dispatch and its - /// raw MotionTableManagerError code. Called by - /// ; self-guarded, so it is - /// a single flag test when the probe is off. - /// - public static void RecordRemoteLandingDispatch(uint motion, uint result) - { - if (!ProbeRemoteLandingEnabled) return; - _remoteLandingApplyCalls++; - _remoteLandingLastApplyMotion = motion; - _remoteLandingLastApplyResult = result; - } - - /// - /// Emit one [remote-landing-after] line for the landing edge whose - /// [remote-landing] line was just written. Caller MUST guard with - /// if (!ProbeRemoteLandingEnabled) return; before calling, and MUST - /// emit it before any post-HitGround ownership re-check can return — a - /// before-line with no after-line therefore means the call site threw. - /// is if a - /// gate short-circuited between the two lines (no such gate exists at - /// either site today; the field exists so the absence is stated rather - /// than inferred from a missing line). - /// - public static void LogRemoteLandingAfter( - string site, - uint guid, - bool hitGroundInvoked, - uint sequencerStyle, - uint sequencerMotion, - uint forwardCommand) - { - var ci = System.Globalization.CultureInfo.InvariantCulture; - Console.WriteLine(string.Format(ci, - "[remote-landing-after] site={0} guid=0x{1:X8} t={2} " + - "hitGroundInvoked={3} seqStyle=0x{4:X8} seqMotion=0x{5:X8} " + - "fwdCmd=0x{6:X8} sinkApplyCalls={7} sinkLastMotion=0x{8:X8} " + - "sinkLastResult=0x{9:X8}", - site, guid, Environment.TickCount64, - hitGroundInvoked, sequencerStyle, sequencerMotion, - forwardCommand, _remoteLandingApplyCalls, - _remoteLandingLastApplyMotion, _remoteLandingLastApplyResult)); - } - // ── [remote-slide-*] — Bug B (remote ledge/roof slide) capture ──────── // // docs/research/2026-08-04-bug-b-remote-slide-diagnosis.md §2 names TWO @@ -851,32 +660,6 @@ public static class PhysicsDiagnostics /// public static ResolvedPolygon? LastBspHitPoly { get; set; } - /// - /// B.6 slice 1 (2026-05-14) — baseline trace for the local-player - /// server-initiated auto-walk path (issue #63). When true, the - /// following events emit one-line [autowalk-*] logs: - /// - /// [autowalk-out] on every SendUse - /// / SendPickUp the local player issues — these are the - /// packets that may trigger ACE's server-side CreateMoveToChain - /// when the target is out of WithinUseRadius. - /// [autowalk-mt] on every inbound - /// UpdateMotion for the local player — captures the - /// MovementType + MoveToPath + speed/runRate ACE sends. - /// [autowalk-up] on every inbound - /// UpdatePosition for the local player — answers "what's - /// ACE's broadcast cadence during auto-walk?" - /// - /// Initial state from ACDREAM_PROBE_AUTOWALK=1. - /// - /// - /// Spec: docs/superpowers/specs/2026-05-14-phase-b6-design.md - /// §"Required investigation". - /// - /// - public static bool ProbeAutoWalkEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_AUTOWALK") == "1"; - /// /// 2026-05-16. Logs one line per `IsUseableTarget` call that takes /// the null-useability fallback path (creature pass / BF_DOOR pass / @@ -998,39 +781,6 @@ public static class PhysicsDiagnostics public static bool ProbeContactPlaneEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_CONTACT_PLANE") == "1"; - /// - /// Indoor walking ISSUES #83 H-disambiguation spike (2026-05-21). - /// When true, two diagnostic emissions activate: - /// - /// One [walk-miss] line per - /// MISS - /// event, dumping foot world/local position, the nearest - /// walkable polygon in the cell (with XY-containment flag and - /// vertical gap), and whether the LandCell terrain at the same - /// XY would have grounded the player. - /// One [floor-polys] line per indoor - /// cell cached, enumerating each walkable-eligible polygon's - /// id, normal Z, local-XY bounding box, and plane Z at the - /// bbox center. - /// - /// Together these answer H1 (multi-cell iteration missing) vs H2 - /// (probe distance too short) vs H3 (poly absent / - /// walkable_hits_sphere rejection) for the ISSUES #83 - /// stuck-falling bug. Spike-only — remove once the root cause is - /// identified and the fix lands. - /// - /// - /// Initial state from ACDREAM_PROBE_WALK_MISS=1. - /// One-shot diagnostic. - /// - /// - /// - /// Spec: docs/superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md. - /// - /// - public static bool ProbeWalkMissEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_WALK_MISS") == "1"; - /// /// Phase A6.P1 cdb probe spike (2026-05-21). When true, every BSP /// collision response site emits a structured [push-back] line: @@ -1148,294 +898,6 @@ public static class PhysicsDiagnostics public static bool ProbeSweptEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_SWEPT") == "1"; - // ----------------------------------------------------------------------- - // #334 broadphase / candidate-disposition probe — TEMPORARY (2026-08-06) - // - // STRIP THIS WHOLE REGION together with the rest of the physics probe - // family once #334 is scored. - // - // #334 is "large static formations can be walked through on flat ground, - // and jumping over them drops you inside". Three candidate mechanisms - // survive the report, and this probe exists to DISCRIMINATE them, not to - // confirm any one of them: - // - // (a) the object IS a candidate in the cell but the broadphase reach - // filter rejects it before its BSP is consulted (AP-158 / #333). - // CONFIRMED and CLOSED 2026-08-06: this was the cause of #337, and - // the filter is now DELETED — retail has none. `rejectedReach` is - // retained as a structurally-zero column so a post-fix capture is - // directly comparable with the pre-fix one that recorded 7,225 - // rejections on one owner, every single one with - // wouldAcceptAtCenter=True; - // (b) the object is NOT in the cell's candidate set at all — a - // membership / registration failure (AP-156's territory, which did - // not fix this, or the static publication path never registered it); - // (c) the object IS a candidate and IS NOT rejected, but resolves to no - // usable shape — an empty shape list or an unresolved physics BSP. - // - // Context for (a): the filter measures |currPos - obj.Position| — the part - // ORIGIN — against obj.Radius (the physics-BSP ROOT sphere radius) plus a - // 2 m acdream-invented slack. The BSP root sphere's CENTRE is frequently - // NOT the part origin (376 of 973 installed physics-BSP parts sit further - // from it than half their own radius; worst 20.762 m). Where that offset - // exceeds the slack, geometry well inside the sphere is rejected. So every - // candidate line carries BOTH distances and the decisive - // wouldAcceptAtCenter boolean. - // - // Context for (c): AP-152 (4abd1b5e) made us emit BSP shapes exclusively - // where primitives were also emitted before. That did NOT cause #334 (the - // user reproduced on a pre-AP-152 build), but the same failure mode can - // exist independently, so no-shape is a first-class disposition here. - // ----------------------------------------------------------------------- - - /// - /// #334 candidate-disposition probe (2026-08-06 — TEMPORARY, strip with - /// the physics-probe family). Emits two line types from - /// Transition.FindObjCollisionsInCell: - /// - /// - /// [reach-q] — one per-cell query summary: the number of - /// shadow entries the cell yielded and the per-disposition tallies. - /// It is emitted even when the cell yields zero entries, which is - /// what makes outcome (b) visible: "cell yielded 0" at a spot where a - /// formation is plainly in front of the player is a registration gap, - /// and is recorded as data rather than as silence. Without this line an - /// absence of rejection lines would be ambiguous between "nothing was - /// rejected" and "nothing was there", which is precisely the - /// unfalsifiable-criterion trap this campaign has already been bitten - /// by. - /// [reach-obj] — one per candidate, carrying its identity - /// (mover guid, target entity id, GfxObj id, cell) and its - /// disposition: exempt-self, exempt-missile, - /// exempt-rule - /// (rejected-reach is retired — #333 deleted the filter that - /// produced it), - /// exempt-ethereal-stepdown, no-shape, - /// bsp-only-skip, or tested:<result>. For BSP - /// candidates it also carries the origin-measured distance the filter - /// used, the centre-measured distance it should have used, the budget, - /// the shortfall, and wouldAcceptAtCenter. - /// - /// - /// - /// Volume control (this site is hot — it runs per cell per transitional - /// insert, and one resolve performs many inserts). [reach-obj] is - /// de-duplicated per (mover, target, cell) and re-emits immediately - /// whenever the disposition changes or the shortfall crosses a 0.5 m - /// bucket, and otherwise at most once per second. [reach-q] is - /// de-duplicated per (mover, cell) on the full tally tuple, so any change - /// in what the cell yielded emits at once, and otherwise at most twice a - /// second. Both therefore emit eagerly on change — which is exactly when - /// the player walks into the formation — and stay quiet when nothing is - /// happening. Nothing is aggregated away: every distinct state the query - /// passes through appears. - /// - /// - /// - /// Initial state from ACDREAM_PROBE_REACH=1. Zero cost when off - /// (one static bool read per query). - /// - /// - public static bool ProbeReachEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_REACH") == "1"; - - private static readonly object _reachGate = new(); - private static readonly Dictionary<(uint Mover, uint Entity, uint Cell), (long Ms, string Disp, int Bucket)> - _reachSeenObj = new(); - private static readonly Dictionary<(uint Mover, uint Cell), (long Ms, long Tally)> - _reachSeenQuery = new(); - - /// - /// One [reach-obj] line. Self-guards on - /// . - /// - /// The moving entity's guid — never omitted; a - /// per-entity probe without an identity produced a wrong root cause once - /// already (feedback_probe_identity_attribution). - /// What happened to this candidate. Use the - /// documented vocabulary on . - /// What the reach filter measured: the distance - /// from the swept sphere's current centre to the target's part ORIGIN. - /// Negative when not applicable. - /// What it should have measured: the distance to - /// the target's physics-BSP root sphere CENTRE. Negative when not - /// applicable. - /// What the deleted filter's admission threshold WOULD - /// have been, 2 m slack included. Since #333 no live predicate reads it; it - /// is kept so the acceptance capture shows which candidates the old filter - /// would have thrown away. - /// The same threshold WITHOUT the slack — the - /// honest conservative bound once the real centre is used. - public static void LogReachCandidate( - uint moverId, - uint entityId, - uint gfxObjId, - uint cellId, - ShadowCollisionType shape, - string disposition, - bool stepDown, - float distOrigin, - float distCenter, - float objRadius, - float sphereRadius, - float movementLen, - float budget, - float centerBudget, - Vector3 objPos, - Vector3 bspCentreOffset, - Vector3 currPos) - { - if (!ProbeReachEnabled) return; - - float shortfall = distOrigin - budget; - bool wouldAcceptAtCenter = distCenter >= 0f && distCenter <= centerBudget; - int bucket = distOrigin < 0f ? 0 : (int)MathF.Floor(shortfall / 0.5f); - long now = Environment.TickCount64; - - lock (_reachGate) - { - var key = (moverId, entityId, cellId); - if (_reachSeenObj.TryGetValue(key, out var prev) - && string.Equals(prev.Disp, disposition, StringComparison.Ordinal) - && prev.Bucket == bucket - && now - prev.Ms < 1000) - { - return; - } - _reachSeenObj[key] = (now, disposition, bucket); - } - - Console.WriteLine(string.Format( - System.Globalization.CultureInfo.InvariantCulture, - "[reach-obj] mover=0x{0:X8} obj=0x{1:X8} gfx=0x{2:X8} cell=0x{3:X8} " + - "disp={4} shape={5} stepDown={6} distOrigin={7:F3} distCenter={8:F3} " + - "objR={9:F3} sphereR={10:F3} move={11:F3} budget={12:F3} " + - "centerBudget={13:F3} shortfall={14:F3} wouldAcceptAtCenter={15} " + - "objPos=({16:F2},{17:F2},{18:F2}) " + - "bspCentreOffset=({19:F2},{20:F2},{21:F2}) |bspCentreOffset|={22:F3} " + - "currPos=({23:F2},{24:F2},{25:F2}) t={26}", - moverId, entityId, gfxObjId, cellId, - disposition, shape, stepDown, distOrigin, distCenter, - objRadius, sphereRadius, movementLen, budget, - centerBudget, shortfall, wouldAcceptAtCenter, - objPos.X, objPos.Y, objPos.Z, - bspCentreOffset.X, bspCentreOffset.Y, bspCentreOffset.Z, bspCentreOffset.Length(), - currPos.X, currPos.Y, currPos.Z, now)); - } - - /// - /// One [reach-q] per-cell query summary. MUST be called even when - /// the cell yields zero entries — that is the whole point of the line. - /// Self-guards on . - /// - /// Shadow entries the cell yielded, before any - /// exemption. Zero here at a spot with visible geometry is outcome (b). - /// Candidates that survived the exemptions and went - /// on to a shape dispatch. Before #333 this was "and were measured by the - /// reach filter"; the filter is gone, so the two are now the same set. - /// Of those, how many the reach filter - /// rejected — outcome (a). Structurally 0 since #333 deleted the - /// filter, and retained precisely so that a post-fix capture reading 0 - /// is comparable against the pre-fix capture that read 7,225. - /// Candidates that passed the filter but resolved to - /// no usable shape — outcome (c). - /// Candidates that actually reached a shape test. - public static void LogReachQuery( - uint moverId, - uint cellId, - bool stepDown, - int inCell, - int exempt, - int reached, - int rejectedReach, - int noShape, - int tested, - int blocked, - Vector3 currPos) - { - if (!ProbeReachEnabled) return; - - // Tally fingerprint: any change in what this cell yielded re-emits at - // once. Deliberately includes every counter, so a state the query - // passes through cannot be swallowed by the throttle. - long tally = (((long)inCell * 31 + exempt) * 31 + reached) * 31; - tally = ((tally + rejectedReach) * 31 + noShape) * 31; - tally = ((tally + tested) * 31 + blocked) * 31 + (stepDown ? 1 : 0); - - long now = Environment.TickCount64; - lock (_reachGate) - { - var key = (moverId, cellId); - if (_reachSeenQuery.TryGetValue(key, out var prev) - && prev.Tally == tally - && now - prev.Ms < 500) - { - return; - } - _reachSeenQuery[key] = (now, tally); - } - - Console.WriteLine(string.Format( - System.Globalization.CultureInfo.InvariantCulture, - "[reach-q] mover=0x{0:X8} cell=0x{1:X8} stepDown={2} inCell={3} " + - "exempt={4} reached={5} rejectedReach={6} noShape={7} tested={8} " + - "blocked={9} pos=({10:F2},{11:F2},{12:F2}) t={13}", - moverId, cellId, stepDown, inCell, - exempt, reached, rejectedReach, noShape, tested, - blocked, currPos.X, currPos.Y, currPos.Z, now)); - } - - // ----------------------------------------------------------------------- - // [support] / [geom] — #337 "what is holding this body up, and is the - // collision geometry where the visual geometry is?" (2026-08-06 — - // TEMPORARY, strip with the physics-probe family). - // - // WHY A NEW FAMILY RATHER THAN MORE [resolve]. - // ACDREAM_PROBE_RESOLVE already prints, per resolve: in/target/out - // position + cell, ok, groundedIn, a THREE-VALUE contact-plane token - // (valid / lastKnown / none), the collision normal + responsible entity if - // something was hit, and one walkable-polygon bool. That is enough to say - // THAT the body stopped. It cannot say WHAT held it up, because it prints - // no plane normal, no plane height, no terrain sample, and no attribution - // for who wrote the plane. So on a "wedged on a rock" capture, (a) terrain - // holding the body, (b) an object surface holding it somewhere other than - // where the rock is drawn, and (c) an unobstructed transition that simply - // fails to advance all produce the SAME [resolve] line. Two diagnoses this - // campaign have already been refuted by measurement; a probe that cannot - // separate the remaining three is not worth the launch. - // - // WHAT SEPARATES THEM. - // [support] — per resolve, per body (players AND corpses/NPCs, which is - // what makes the fall-through case observable at all). It samples the - // OUTDOOR TERRAIN independently at the body's own out-XY and prints - // the contact plane's own height at that same XY. Two independent - // heights at one point: - // cpZ@out == terrZ → terrain is the support, whatever set it. - // cpZ@out >> terrZ → an object surface is the support. - // cpValid=false → nothing is; the body is in free fall. - // `cpSrc` names the code site that wrote the plane, so the classifier - // and the provenance are cross-checkable rather than one inferring - // the other. - // [geom] — once per GfxObj that comes near the mover. Compares the - // object's PHYSICS BSP vertex cloud against its VISUAL mesh AABB in - // the same local frame. If the collision geometry is absent, empty, - // displaced, or the wrong size, this line says so directly. That is - // the working hypothesis's refutation test: `verdict=coincident` - // kills "the collision isn't where the visual is" outright, and no - // amount of movement-side evidence is then needed to rule it out. - // - // Neither line gates, orders, or mutates anything. Both are pure reads. - // ----------------------------------------------------------------------- - - /// - /// Initial state from ACDREAM_PROBE_SUPPORT=1. Enables the - /// [support] and [geom] lines described above. Zero cost when - /// off (one static bool read per resolve and per collision candidate). - /// TEMPORARY — strip with the rest of the physics-probe family. - /// - public static bool ProbeSupportEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_SUPPORT") == "1"; - /// /// Initial state from ACDREAM_PROBE_JUMP=1. Campaign CH user-gate /// round 2, item 1 (jump-in-air refusal reported STILL silent live after @@ -1449,500 +911,6 @@ public static class PhysicsDiagnostics public static bool ProbeJumpEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_JUMP") == "1"; - /// Vertical agreement window, in metres, inside which the contact - /// plane's height and the terrain's height at the same XY are called the - /// same surface. - public const float SupportSameSurfaceZ = 0.05f; - - /// Straight-up-component agreement window inside which the contact - /// plane's tilt and the terrain triangle's tilt are called the same - /// surface. 0.02 is roughly 1 degree near flat. - public const float SupportSameSurfaceNormalZ = 0.02f; - - private static readonly object _supportGate = new(); - private static readonly Dictionary _supportSeen = new(); - private static readonly HashSet _geomSeen = new(); - - // Contact-plane provenance latch. Ten distinct sites call - // CollisionInfo.SetContactPlane — terrain, object BSP (graph and flat), - // cell BSP, three water paths, and a straight-up fallback — and the plane - // they write is indistinguishable once stored, so [support]'s - // classification would have no independent cross-check. - // - // This deliberately does NOT live on CollisionInfo. That object's stored - // members are compared member-for-member by the flat/graph differential - // referee and by the transition-scratch reset poison test; adding a - // diagnostic field there makes both oracles report a difference that is - // not a difference, and the only way to keep them green is to teach them - // to skip a member — which is how a referee quietly stops refereeing. - // [ThreadStatic] because a headless host ticks several sessions in - // parallel and physics is synchronous within each. - [ThreadStatic] private static string? _contactPlaneSourceMember; - [ThreadStatic] private static int _contactPlaneSourceLine; - - /// - /// Clear the provenance latch. Call once per resolve, before the sweep, so - /// a resolve that establishes no plane reports none rather than the - /// previous resolve's answer. No-op unless - /// . - /// - public static void BeginContactPlaneAttribution() - { - if (!ProbeSupportEnabled) return; - _contactPlaneSourceMember = null; - _contactPlaneSourceLine = 0; - } - - /// - /// Record the site asserting a contact plane. Called by - /// CollisionInfo.SetContactPlane with compiler-supplied literals; - /// self-guarded, so it is a single flag test when the probe is off and - /// allocates nothing when it is on. - /// - public static void RecordContactPlaneSource(string member, int line) - { - if (!ProbeSupportEnabled) return; - _contactPlaneSourceMember = member; - _contactPlaneSourceLine = line; - } - - /// - /// member:line of the last site to assert a contact plane since - /// , or "none". - /// - public static string ContactPlaneSource => - _contactPlaneSourceMember is { Length: > 0 } member - ? string.Concat( - member, - ":", - _contactPlaneSourceLine.ToString( - System.Globalization.CultureInfo.InvariantCulture)) - : "none"; - - /// - /// Classify what is under the body. Pure function so the live probe and any - /// offline reader agree on the vocabulary. - /// - /// none — no contact plane: the body is unsupported. - /// terrain — the contact plane sits at the terrain's height - /// AND shares its tilt. - /// object — the contact plane sits clear of the terrain: some - /// collision surface other than the ground is the support. - /// coplanar-tilt-mismatch — same height, different tilt. - /// Reported as its own answer rather than folded into either, because - /// it is exactly what a collision mesh laid flat against the ground - /// would look like and guessing between the two would be the third - /// unverified diagnosis this campaign. - /// no-terrain — no outdoor terrain under this XY (indoors, - /// or the landblock is not resident): the comparison is unavailable - /// and is said so rather than defaulted. - /// - /// - public static string ClassifySupport( - bool contactPlaneValid, - bool terrainSampled, - float contactPlaneZAtXY, - float contactPlaneNormalZ, - float terrainZ, - float terrainNormalZ) - { - if (!contactPlaneValid) return "none"; - if (!terrainSampled) return "no-terrain"; - - bool sameHeight = MathF.Abs(contactPlaneZAtXY - terrainZ) <= SupportSameSurfaceZ; - bool sameTilt = MathF.Abs(contactPlaneNormalZ - terrainNormalZ) <= SupportSameSurfaceNormalZ; - - if (sameHeight && sameTilt) return "terrain"; - if (sameHeight) return "coplanar-tilt-mismatch"; - return "object"; - } - - /// - /// Evaluate a plane's height at a given XY. Returns - /// when the plane is near-vertical, where a height is not defined — a wall - /// is never a floor, and reporting a huge number for one would read as a - /// displaced surface. - /// - public static bool TryPlaneZAt(in Plane plane, float x, float y, out float z) - { - float nz = plane.Normal.Z; - if (MathF.Abs(nz) < 1e-4f) - { - z = float.NaN; - return false; - } - z = -(plane.D + plane.Normal.X * x + plane.Normal.Y * y) / nz; - return true; - } - - /// - /// One [support] line. Self-guards on - /// . - /// - /// - /// Volume control: per mover, the line re-emits IMMEDIATELY on any change - /// in the state signature — the support classification, the ok / contact / - /// walkable / stalled bits, a 0.1 m change in the body's height, or a - /// 0.1 m change in its height above terrain — and otherwise at most once - /// per 250 ms. A body falling through geometry therefore produces a line - /// every 10 cm of descent, and a body standing still produces four lines a - /// second. Nothing is aggregated away. - /// - /// - /// - /// is never omitted: - /// feedback_probe_identity_attribution — a per-entity probe without - /// an identity produced a wrong root cause once already, and this capture - /// deliberately covers several bodies at once. - /// - /// - public static void LogSupport( - uint moverId, - bool isPlayer, - Vector3 inPos, - uint inCell, - Vector3 targetPos, - Vector3 outPos, - uint outCell, - bool ok, - bool groundedIn, - bool contact, - bool onWalkable, - bool contactPlaneValid, - Plane contactPlane, - uint contactPlaneCellId, - bool contactPlaneIsWater, - string contactPlaneSource, - bool lastKnownValid, - Plane lastKnownPlane, - bool terrainSampled, - float terrainZ, - Vector3 terrainNormal, - uint terrainCellId, - bool terrainIsWater, - bool walkablePolygon, - bool lastWalkablePolygon, - float stepUpHeight, - float stepDownHeight, - Vector3 velocity) - { - if (!ProbeSupportEnabled) return; - - float cpZ = float.NaN; - bool cpZDefined = contactPlaneValid - && TryPlaneZAt(contactPlane, outPos.X, outPos.Y, out cpZ); - if (!cpZDefined) cpZ = float.NaN; - - float cpNz = contactPlaneValid ? contactPlane.Normal.Z : float.NaN; - float terrNz = terrainSampled ? terrainNormal.Z : float.NaN; - - string support = ClassifySupport( - contactPlaneValid && cpZDefined, - terrainSampled, - cpZ, - cpNz, - terrainZ, - terrNz); - - float commanded = Vector3.Distance(inPos, targetPos); - float moved = Vector3.Distance(inPos, outPos); - // "The body was told to move and did not." The 1 cm floor is the - // resolver's own no-op scale, not a tuned threshold. - bool stalled = commanded > 0.01f && moved <= 0.01f; - - float zAboveTerrain = terrainSampled ? outPos.Z - terrainZ : float.NaN; - float cpAboveTerrain = terrainSampled && cpZDefined ? cpZ - terrainZ : float.NaN; - - long now = Environment.TickCount64; - long signature = support.GetHashCode(); - signature = signature * 31 + (ok ? 1 : 0); - signature = signature * 31 + (groundedIn ? 1 : 0); - signature = signature * 31 + (contact ? 1 : 0); - signature = signature * 31 + (onWalkable ? 1 : 0); - signature = signature * 31 + (contactPlaneValid ? 1 : 0); - signature = signature * 31 + (stalled ? 1 : 0); - signature = signature * 31 + (long)MathF.Floor(outPos.Z * 10f); - signature = signature * 31 + (float.IsNaN(zAboveTerrain) - ? 0 - : (long)MathF.Floor(zAboveTerrain * 10f)); - - lock (_supportGate) - { - if (_supportSeen.TryGetValue(moverId, out var prev) - && prev.Signature == signature - && now - prev.Ms < 250) - { - return; - } - _supportSeen[moverId] = (now, signature); - } - - var ci = System.Globalization.CultureInfo.InvariantCulture; - Console.WriteLine(string.Format(ci, - "[support] mover=0x{0:X8} isPlayer={1} t={2} support={3} " + - "in=({4:F3},{5:F3},{6:F3}) inCell=0x{7:X8} " + - "tgt=({8:F3},{9:F3},{10:F3}) out=({11:F3},{12:F3},{13:F3}) outCell=0x{14:X8} " + - "ok={15} cmd={16:F3} moved={17:F3} stalled={18} " + - "groundedIn={19} contact={20} onWalkable={21} " + - "cpValid={22} cpSrc={23} cpCell=0x{24:X8} cpWater={25} " + - "cpN=({26:F4},{27:F4},{28:F4}) cpNz={29:F4} floorZ={30:F4} cpWalkable={31} " + - "cpZatOut={32:F3} " + - "lkcpValid={33} lkcpNz={34:F4} " + - "terrOk={35} terrZ={36:F3} terrNz={37:F4} terrWalkable={38} " + - "terrCell=0x{39:X8} terrWater={40} " + - "zAboveTerr={41:F3} cpAboveTerr={42:F3} " + - "walkPoly={43} lastWalkPoly={44} stepUp={45:F3} stepDown={46:F3} " + - "vel=({47:F3},{48:F3},{49:F3})", - moverId, isPlayer, now, support, - inPos.X, inPos.Y, inPos.Z, inCell, - targetPos.X, targetPos.Y, targetPos.Z, - outPos.X, outPos.Y, outPos.Z, outCell, - ok, commanded, moved, stalled, - groundedIn, contact, onWalkable, - contactPlaneValid, contactPlaneSource, contactPlaneCellId, contactPlaneIsWater, - contactPlaneValid ? contactPlane.Normal.X : float.NaN, - contactPlaneValid ? contactPlane.Normal.Y : float.NaN, - cpNz, cpNz, PhysicsGlobals.FloorZ, - contactPlaneValid && cpNz >= PhysicsGlobals.FloorZ, - cpZ, - lastKnownValid, lastKnownValid ? lastKnownPlane.Normal.Z : float.NaN, - terrainSampled, terrainZ, terrNz, - terrainSampled && terrNz >= PhysicsGlobals.FloorZ, - terrainCellId, terrainIsWater, - zAboveTerrain, cpAboveTerrain, - walkablePolygon, lastWalkablePolygon, stepUpHeight, stepDownHeight, - velocity.X, velocity.Y, velocity.Z)); - } - - /// - /// Ask whether [geom] has already been emitted for this GfxObj. - /// The line is a property of the ASSET, not of any moment, so once per - /// process is the whole story and re-emitting it would bury the - /// [support] stream. - /// - public static bool ShouldLogGeometry(uint gfxObjId) - { - if (!ProbeSupportEnabled) return false; - lock (_supportGate) - { - return _geomSeen.Add(gfxObjId); - } - } - - /// - /// One [geom] line: is this object's collision geometry where its - /// visual geometry is? Caller MUST have claimed the id through - /// . - /// - /// - /// The verdict vocabulary, and what each one settles: - /// - /// no-physics-bsp / empty-physics-bsp — the object - /// has no collision polygons at all. Everything a body does around it - /// follows from that one fact and no movement-side theory is needed. - /// no-visual-bounds — the comparison could not be made. Said - /// out loud rather than silently treated as agreement. - /// displaced — collision and visual are the same size but - /// sit in different places. This is the working hypothesis, and this - /// token is the only thing that confirms it. - /// extent-mismatch — same place, different size. - /// coincident — collision and visual agree. This REFUTES the - /// working hypothesis for this object, and the cause is then on the - /// movement side (terrain support, or the transition itself). - /// - /// - /// - public static void LogGeometry( - uint gfxObjId, - uint entityId, - int bspNodeCount, - int bspPolygonCount, - int bspVertexCount, - Vector3 rootSphereOrigin, - float rootSphereRadius, - bool physicsBoundsValid, - Vector3 physicsMin, - Vector3 physicsMax, - bool visualBoundsValid, - Vector3 visualMin, - Vector3 visualMax, - float visualRadius, - Vector3 entityWorldPosition, - float entityScale, - float registeredRadius) - { - Vector3 physExtent = physicsBoundsValid ? physicsMax - physicsMin : Vector3.Zero; - Vector3 visExtent = visualBoundsValid ? visualMax - visualMin : Vector3.Zero; - Vector3 physCentre = physicsBoundsValid - ? (physicsMin + physicsMax) * 0.5f - : Vector3.Zero; - Vector3 visCentre = visualBoundsValid - ? (visualMin + visualMax) * 0.5f - : Vector3.Zero; - - float centreDelta = physicsBoundsValid && visualBoundsValid - ? Vector3.Distance(physCentre, visCentre) - : float.NaN; - - // Tolerances are deliberately loose: this line answers "same place, - // same size?" at the scale of a rock formation, not to the millimetre. - // A physics hull is a coarse stand-in for the render mesh, so a - // half-metre of centre drift or a 2x extent ratio is normal; what this - // is looking for is the pathological case. - float centreTolerance = visualBoundsValid - ? MathF.Max(0.5f, visualRadius * 0.25f) - : 0.5f; - - bool extentMismatch = false; - if (physicsBoundsValid && visualBoundsValid) - { - for (int axis = 0; axis < 3; axis++) - { - float p = axis == 0 ? physExtent.X : axis == 1 ? physExtent.Y : physExtent.Z; - float v = axis == 0 ? visExtent.X : axis == 1 ? visExtent.Y : visExtent.Z; - // Flat axes (a floor plate) legitimately have ~0 extent in one - // dimension on both sides; only compare where the visual has - // real size. - if (v < 0.1f) continue; - float ratio = p / v; - if (ratio is < 0.5f or > 2.0f) extentMismatch = true; - } - } - - string verdict = - bspNodeCount == 0 ? "no-physics-bsp" - : bspPolygonCount == 0 ? "empty-physics-bsp" - : !visualBoundsValid ? "no-visual-bounds" - : !physicsBoundsValid ? "no-physics-bounds" - : centreDelta > centreTolerance ? "displaced" - : extentMismatch ? "extent-mismatch" - : "coincident"; - - var ci = System.Globalization.CultureInfo.InvariantCulture; - Console.WriteLine(string.Format(ci, - "[geom] gfx=0x{0:X8} verdict={1} entity=0x{2:X8} t={3} " + - "bspNodes={4} bspPolys={5} bspVerts={6} " + - "rootSphere=({7:F3},{8:F3},{9:F3}) rootR={10:F3} registeredR={11:F3} " + - "physMin=({12:F3},{13:F3},{14:F3}) physMax=({15:F3},{16:F3},{17:F3}) " + - "physExt=({18:F3},{19:F3},{20:F3}) " + - "visMin=({21:F3},{22:F3},{23:F3}) visMax=({24:F3},{25:F3},{26:F3}) " + - "visExt=({27:F3},{28:F3},{29:F3}) visR={30:F3} " + - "centreDelta={31:F3} centreTol={32:F3} extentMismatch={33} " + - "objPos=({34:F2},{35:F2},{36:F2}) scale={37:F3} " + - "physWorldZ=[{38:F2},{39:F2}] visWorldZ=[{40:F2},{41:F2}]", - gfxObjId, verdict, entityId, Environment.TickCount64, - bspNodeCount, bspPolygonCount, bspVertexCount, - rootSphereOrigin.X, rootSphereOrigin.Y, rootSphereOrigin.Z, - rootSphereRadius, registeredRadius, - physicsMin.X, physicsMin.Y, physicsMin.Z, - physicsMax.X, physicsMax.Y, physicsMax.Z, - physExtent.X, physExtent.Y, physExtent.Z, - visualMin.X, visualMin.Y, visualMin.Z, - visualMax.X, visualMax.Y, visualMax.Z, - visExtent.X, visExtent.Y, visExtent.Z, visualRadius, - centreDelta, centreTolerance, extentMismatch, - entityWorldPosition.X, entityWorldPosition.Y, entityWorldPosition.Z, - entityScale, - // Rotation is NOT applied to these two world Z ranges: an - // axis-aligned box is not rotation-invariant, so a rotated object - // would report a box that is merely indicative. Both sides get the - // SAME treatment, so their AGREEMENT (the thing being measured) - // stays exact regardless. - entityWorldPosition.Z + physicsMin.Z * entityScale, - entityWorldPosition.Z + physicsMax.Z * entityScale, - entityWorldPosition.Z + visualMin.Z * entityScale, - entityWorldPosition.Z + visualMax.Z * entityScale)); - } - - /// - /// Resolve the collision-vs-visual comparison for one GfxObj straight from - /// the SAME prepared assets the resolver itself queries, and emit its - /// [geom] line. Going through the production accessors is the point: - /// AP-156's lesson was that a probe reading geometry by a second route can - /// report a shape the registry never emitted. Caller MUST have claimed the - /// id through . - /// - /// - /// The physics box is measured over the vertices of the polygons the BSP - /// actually indexes, not over the whole polygon table — a table can carry - /// rows no node references, and including those would report collision - /// geometry that no query can ever reach. - /// - /// - public static void LogGeometryFromAssets( - uint gfxObjId, - uint entityId, - FlatGfxObjCollisionAsset? flat, - GfxObjVisualBounds? visual, - Vector3 entityWorldPosition, - float entityScale, - float registeredRadius) - { - int nodeCount = 0; - int polygonCount = 0; - int vertexCount = 0; - Vector3 rootOrigin = Vector3.Zero; - float rootRadius = 0f; - bool physBoundsValid = false; - var physMin = new Vector3(float.PositiveInfinity); - var physMax = new Vector3(float.NegativeInfinity); - - FlatPhysicsBsp? bsp = flat?.PhysicsBsp; - if (bsp is { RootIndex: >= 0 } && bsp.Nodes.Length > 0) - { - nodeCount = bsp.Nodes.Length; - rootOrigin = bsp.Nodes[bsp.RootIndex].BoundingSphere.Origin; - rootRadius = bsp.Nodes[bsp.RootIndex].BoundingSphere.Radius; - - FlatPolygonTable table = bsp.PolygonTable; - foreach (FlatPhysicsBspNode node in bsp.Nodes) - { - FlatIndexRange range = node.PolygonIndexRange; - for (int i = range.Start; i < range.EndExclusive; i++) - { - int polygonIndex = bsp.PolygonIndexStream[i]; - if ((uint)polygonIndex >= (uint)table.Polygons.Length) continue; - - polygonCount++; - FlatIndexRange vertices = table.Polygons[polygonIndex].VertexRange; - for (int v = vertices.Start; v < vertices.EndExclusive; v++) - { - Vector3 p = table.Vertices[v]; - vertexCount++; - physMin = Vector3.Min(physMin, p); - physMax = Vector3.Max(physMax, p); - physBoundsValid = true; - } - } - } - } - - if (!physBoundsValid) - { - physMin = Vector3.Zero; - physMax = Vector3.Zero; - } - - LogGeometry( - gfxObjId: gfxObjId, - entityId: entityId, - bspNodeCount: nodeCount, - bspPolygonCount: polygonCount, - bspVertexCount: vertexCount, - rootSphereOrigin: rootOrigin, - rootSphereRadius: rootRadius, - physicsBoundsValid: physBoundsValid, - physicsMin: physMin, - physicsMax: physMax, - visualBoundsValid: visual is not null, - visualMin: visual?.Min ?? Vector3.Zero, - visualMax: visual?.Max ?? Vector3.Zero, - visualRadius: visual?.Radius ?? 0f, - entityWorldPosition: entityWorldPosition, - entityScale: entityScale, - registeredRadius: registeredRadius); - } - /// /// Teleport-foundation timing probe (2026-06-22 — REMOVABLE diagnostic). /// Emits one [tp-probe] line per teleport-pipeline event with a @@ -2140,36 +1108,18 @@ public static class PhysicsDiagnostics ProbeParkEnabled = false; ProbeBuildingEnabled = false; ProbeCellSetEnabled = false; - ProbeStickyEnabled = false; - ProbeAutoWalkEnabled = false; ProbeUseabilityFallbackEnabled= false; DumpSteepRoofEnabled = false; ProbeIndoorBspEnabled = false; ProbeCellCacheEnabled = false; ProbeContactPlaneEnabled = false; - ProbeWalkMissEnabled = false; ProbePushBackEnabled = false; ProbePolyDumpEnabled = false; ProbePlacementFailEnabled = false; ProbeSweptEnabled = false; ProbeStepWalkEnabled = false; - ProbeReachEnabled = false; - lock (_reachGate) - { - _reachSeenObj.Clear(); - _reachSeenQuery.Clear(); - } - ProbeSupportEnabled = false; - _contactPlaneSourceMember = null; - _contactPlaneSourceLine = 0; - lock (_supportGate) - { - _supportSeen.Clear(); - _geomSeen.Clear(); - } ProbeTeleportEnabled = false; ProbeRemoteTeleportEnabled = false; - ProbeRemoteLandingEnabled = false; ProbeRemoteSlideEnabled = false; ProbeRemoteSlideGuids = new System.Collections.Generic.HashSet(); _remoteSlideAttributionGuid = 0; @@ -2550,130 +1500,6 @@ public static class PhysicsDiagnostics return "?"; } - // ----------------------------------------------------------------------- - // #338 — step-height provenance - // ----------------------------------------------------------------------- - - /// - /// #338 (2026-08-06, TEMPORARY). Traces the LOCAL PLAYER's step-up / - /// step-down heights along the chain that is supposed to carry them from - /// the authored Setup to the resolver, so we can see WHERE the observed - /// 0.400 / 0.400 wins. - /// - /// - /// What is already known and does NOT need measuring: retail reads the - /// authored field (CTransition::step_up @0x0050b610 substitutes - /// object_info.step_up_height when state & 2, i.e. - /// OnWalkable, and otherwise uses 0.0399999991f), and acdream - /// already ports that gate faithfully in Transition.DoStepUp. The - /// value is the defect, not the gate. - /// - /// - /// - /// What is NOT known, and is exactly what this probe decides: the - /// controller's fields initialise to 0.4f, while - /// RuntimeSetPositionMoverPreparation genuinely computes the - /// Setup-derived value and a writer genuinely assigns it. So either the - /// prepare/publish path never runs for the local player, or it runs and - /// something later overwrites it. A live reading of 0.400 alone - /// cannot tell those apart, and a fix chosen without knowing which is a - /// coin flip. - /// - /// - /// Decision table. Read the [step-h] lines in order: - /// - /// No site=prepare line at all → the Setup-derived path - /// never runs for the local player. Fix is to wire it (and the missing - /// PlayerModeController.ApplyStepHeights the doc comment names - /// was probably it). - /// site=prepare shows 0.600/1.500 but no - /// site=publish → the command is built and never consumed on this - /// path. - /// prepare and publish both show 0.600/1.500 but - /// site=resolve shows 0.400 → a later writer clobbers it; find - /// that writer, do not re-set the field. - /// site=prepare itself shows 0.400 → the Setup lookup is - /// returning the wrong Setup, or scale is wrong. - /// site=prepare shows 0.000 → preparation.Setup.Collision - /// was null and the retail dummy path was taken. - /// - /// - /// - /// - /// Initial state from ACDREAM_PROBE_STEP_HEIGHTS=1. Zero cost when - /// off (one static bool read per site). - /// - /// - public static bool ProbeStepHeightsEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_STEP_HEIGHTS") == "1"; - - private static readonly object _stepHeightGate = new(); - private static readonly Dictionary _stepHeightSeen = new(); - - private static int _stepHeightAnnounced; - - /// - /// #338 (TEMPORARY). Prints ONCE per process, regardless of the flag, from - /// the resolve site. Two placements of this probe produced no output at - /// all, and silence cannot distinguish "the site is never reached" from - /// "the flag is false" — so the instrument reports its own state rather - /// than leaving that to inference. Reaching this call proves the site - /// executes; the printed values say whether the flag and the player filter - /// would have let anything through. - /// - public static void AnnounceStepHeightProbeOnce(bool isPlayerMover) - { - if (System.Threading.Interlocked.Exchange(ref _stepHeightAnnounced, 1) != 0) - return; - - Console.WriteLine( - $"[step-h] SELF-REPORT: resolve site reached. " - + $"ProbeStepHeightsEnabled={ProbeStepHeightsEnabled} " - + $"(ACDREAM_PROBE_STEP_HEIGHTS=" - + $"{Environment.GetEnvironmentVariable("ACDREAM_PROBE_STEP_HEIGHTS") ?? ""}) " - + $"firstMoverIsPlayer={isPlayerMover}"); - // Which BINARY is this? The 2026-08-07 wrong-checkout incident: two - // checkouts, a relative launch path, and every "test" of a fix ran a - // binary that did not contain it. Assembly identity belongs IN the - // capture, not inferred from bin timestamps afterwards. - Console.WriteLine( - $"[step-h] SELF-REPORT: assembly=" - + $"{typeof(PhysicsDiagnostics).Assembly.Location}"); - } - - /// - /// One [step-h] line. Self-guards on - /// , and is edge-triggered per site: - /// a site that keeps reporting the same pair prints once, so the - /// per-tick resolve site cannot drown the two one-shot sites it has to be - /// compared against. - /// - /// Where on the chain this reading was taken — - /// prepare, publish, or resolve. - /// The step-up height at that point, in metres. - /// The step-down height at that point. - /// Free-form provenance: the Setup id, the scale, or - /// which branch produced the value. This is what makes a surprising - /// reading actionable instead of merely surprising. - public static void LogStepHeights( - string site, float stepUp, float stepDown, string detail) - { - if (!ProbeStepHeightsEnabled) return; - - lock (_stepHeightGate) - { - if (_stepHeightSeen.TryGetValue(site, out var prev) - && prev.Up == stepUp && prev.Down == stepDown) - { - return; - } - _stepHeightSeen[site] = (stepUp, stepDown); - } - - Console.WriteLine( - $"[step-h] site={site} stepUp={stepUp:F3} stepDown={stepDown:F3} {detail}"); - } - // ------------------------------------------------------------------ // S6 (Campaign S, 2026-08-07) — AP-83/AP-91 PerfectClip TOI-tail // containment guard. diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 0eaa5fee..103e4d20 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -1913,15 +1913,6 @@ public sealed class PhysicsEngine ? PhysicsResolveCapture.Snapshot(body) : null; - // #337 (2026-08-06 — TEMPORARY): arm the [support] probe's - // contact-plane provenance latch for this resolve, ahead of everything - // including the carried-plane seed below. The seed is itself one of - // the ten sites that assert a plane, so it stamps its own name and a - // capture can read `cpSrc=ResolveWithTransition:` as "carried - // from the body, nothing re-derived it this resolve" without needing a - // sentinel value for that case. No-op when the probe is off. - PhysicsDiagnostics.BeginContactPlaneAttribution(); - // #345 probe (2026-08-08): reset the per-tick transition-phase trace // buffer. Scoped to exactly one resolve/tick — see // PhysicsDiagnostics.BeginTransitFailTrace. No-op when the probe is @@ -1934,38 +1925,6 @@ public sealed class PhysicsEngine transition.ObjectInfo.StepUpHeight = stepUpHeight; transition.ObjectInfo.StepDownHeight = stepDownHeight; - // #338 (TEMPORARY): the resolver's own reading, taken where the - // values actually land rather than at one of two candidate call - // sites. The first attempt probed PlayerMovementController and - // printed NOTHING across 11,523 live log lines — the wrong one of - // its two resolve calls. A silent probe proves nothing, so this - // one sits where every caller must pass through. Filtered to the - // player so remotes cannot drown it. - // #338 self-report, once per process, UNCONDITIONAL. The probe has - // now been silent through two placements, and "no output" cannot - // distinguish "this site is never reached" from "the flag is - // false". This line answers both directly instead of a third round - // of inference. One Interlocked per process; strip with the probe. - PhysicsDiagnostics.AnnounceStepHeightProbeOnce( - (moverFlags & ObjectInfoState.IsPlayer) != 0); - - // The flag test MUST precede the interpolated string: this site runs - // per resolve, and building the detail eagerly cost 128 B/resolve - // with the probe OFF — caught by Slice I1's zero-allocation gate, - // which is exactly what that gate is for. - if (PhysicsDiagnostics.ProbeStepHeightsEnabled - && (moverFlags & ObjectInfoState.IsPlayer) != 0) - { - // The mover id is REQUIRED here (feedback_probe_identity_attribution): - // remote players also carry IsPlayer, and the one early - // 0.400 reading this probe caught was nearly misattributed to - // the local player for exactly that reason — it was a remote - // in its Setup-residency window (AD-68). - PhysicsDiagnostics.LogStepHeights( - "resolve", stepUpHeight, stepDownHeight, - $"mover=0x{movingEntityId:X8} onGround={isOnGround} hasBody={body is not null}"); - } - transition.ObjectInfo.StepDown = true; // Fix #42 (2026-05-05): the moving entity's ShadowEntry must be // skipped in FindObjCollisions or the sweep collides with self. @@ -2286,33 +2245,6 @@ public sealed class PhysicsEngine bool collisionNormalValid = ci.CollisionNormalValid; Vector3 collisionNormal = ci.CollisionNormal; - // #42 diagnostic (2026-05-05): trace airborne sweeps to identify the - // source of the ~1m XY drift on retail-observed stationary jumps. - // Gated on ACDREAM_AIRBORNE_DIAG=1 and !isOnGround. One line per - // resolve call. deltaXY = post - target tells us how much the sweep - // diverged from the requested target; for a clean stationary +Z - // jump we expect (0,0). cp=valid with a tilted normal would confirm - // H1 (initial-overlap depenetration → next-step AdjustOffset projects - // the +Z offset along a non-+Z normal). User repros at flat plaza / - // east hillside / north hillside; if drift direction tracks terrain - // orientation, H1 is the cause; if it tracks actor facing, H2 / H3. - if (!isOnGround - && Environment.GetEnvironmentVariable("ACDREAM_AIRBORNE_DIAG") == "1") - { - var post = sp.CheckPos; - float dx = post.X - targetPos.X; - float dy = post.Y - targetPos.Y; - string cpInfo = ci.ContactPlaneValid - ? $"valid cpN=({ci.ContactPlane.Normal.X:F3},{ci.ContactPlane.Normal.Y:F3},{ci.ContactPlane.Normal.Z:F3})" - : "none"; - Console.WriteLine( - $"[SWEEP] airborne pre=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) " + - $"target=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) " + - $"post=({post.X:F3},{post.Y:F3},{post.Z:F3}) " + - $"cell={cellId:X8}->{sp.CheckCellId:X8} ok={ok} " + - $"deltaXY=({dx:F3},{dy:F3}) cp={cpInfo}"); - } - // L.2a slice 1 (2026-05-12): general-purpose resolver probe. // One line per call when PhysicsDiagnostics.ProbeResolveEnabled // is set (env var ACDREAM_PROBE_RESOLVE=1 at startup, or the @@ -2355,67 +2287,6 @@ public sealed class PhysicsEngine $"[resolve] ent=0x{movingEntityId:X8} in=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cell=0x{cellId:X8} tgt=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) out=({probePost.X:F3},{probePost.Y:F3},{probePost.Z:F3}) cell=0x{sp.CheckCellId:X8} ok={ok} groundedIn={isOnGround} cp={probeCp} hit={probeHit} walkable={sp.HasLastWalkablePolygon}")); } - // #337 [support] probe (2026-08-06 — TEMPORARY, strip with the - // physics-probe family). Runs for EVERY body, not just the player: - // a corpse sinking through geometry is a plain physics body with - // no player-specific logic, so it is the cheapest possible control - // on whether the movement code or the geometry is at fault, and it - // is invisible to any player-filtered probe. - // - // The terrain sample below is INDEPENDENT of whatever the sweep - // decided — it asks the landblock directly what the ground height - // is under the body's own out-XY. Pairing that with the contact - // plane's height at the same XY is what separates "terrain is - // holding this body up" from "some object surface is". Read-only: - // SampleTerrainWalkable takes no locks, mutates nothing, and is - // not on the resolve's committed path. - if (PhysicsDiagnostics.ProbeSupportEnabled) - { - Vector3 outPos = sp.CheckPos; - TerrainWalkableSample? terrain = - SampleTerrainWalkable(outPos.X, outPos.Y); - - bool terrainSampled = terrain.HasValue - && PhysicsDiagnostics.TryPlaneZAt( - terrain.Value.Plane, outPos.X, outPos.Y, out _); - float terrainZ = float.NaN; - if (terrainSampled) - { - PhysicsDiagnostics.TryPlaneZAt( - terrain!.Value.Plane, outPos.X, outPos.Y, out terrainZ); - } - - PhysicsDiagnostics.LogSupport( - moverId: movingEntityId, - isPlayer: (moverFlags & ObjectInfoState.IsPlayer) != 0, - inPos: currentPos, - inCell: cellId, - targetPos: targetPos, - outPos: outPos, - outCell: sp.CheckCellId, - ok: ok, - groundedIn: isOnGround, - contact: transition.ObjectInfo.Contact, - onWalkable: transition.ObjectInfo.OnWalkable, - contactPlaneValid: ci.ContactPlaneValid, - contactPlane: ci.ContactPlane, - contactPlaneCellId: ci.ContactPlaneCellId, - contactPlaneIsWater: ci.ContactPlaneIsWater, - contactPlaneSource: PhysicsDiagnostics.ContactPlaneSource, - lastKnownValid: ci.LastKnownContactPlaneValid, - lastKnownPlane: ci.LastKnownContactPlane, - terrainSampled: terrainSampled, - terrainZ: terrainZ, - terrainNormal: terrain?.Plane.Normal ?? Vector3.Zero, - terrainCellId: terrain?.CellId ?? 0u, - terrainIsWater: terrain?.IsWater ?? false, - walkablePolygon: sp.HasWalkablePolygon, - lastWalkablePolygon: sp.HasLastWalkablePolygon, - stepUpHeight: stepUpHeight, - stepDownHeight: stepDownHeight, - velocity: body?.Velocity ?? Vector3.Zero); - } - // Phase W Stage 0 (2026-06-02): [cell-swept] probe — swept cell vs static-derived cell. // Emits before the ResolveResult is built so it shows what BOTH paths would return. // No ResolveCellId call here (it has a CellGraph.CurrCell side effect). No behavior change. diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 051bf8c7..7c60c224 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -446,29 +446,8 @@ public sealed class CollisionInfo public void SetContactPlane( Plane plane, uint cellId, - bool isWater = false, - // #337 [support] attribution — recorded on PhysicsDiagnostics, not on - // this object; see the comment in the body for why. Compiler-supplied - // literals: no call site passes these explicitly and none needs to. - [System.Runtime.CompilerServices.CallerMemberName] string sourceMember = "", - [System.Runtime.CompilerServices.CallerLineNumber] int sourceLine = 0) + bool isWater = false) { - // #337 attribution (2026-08-06 — TEMPORARY, strip with the probe - // family). Recorded ABOVE the no-op guard on purpose: the meaning is - // "the last site that ASSERTED this plane", not "the site that first - // differed from the previous value" — a sweep that re-derives the - // identical plane it was seeded with has still told you where the - // plane comes from, and that is the fact the capture needs. - // - // It lives on PhysicsDiagnostics, NOT on this object. CollisionInfo's - // stored members are compared member-for-member by the flat/graph - // differential referee and by the scratch-reset poison test; a - // diagnostic field there is state those oracles must then be taught to - // ignore, which is how a referee stops refereeing. The latch is - // [ThreadStatic] and is armed once per resolve — see - // PhysicsDiagnostics.BeginContactPlaneAttribution. - PhysicsDiagnostics.RecordContactPlaneSource(sourceMember, sourceLine); - // A6.P3 slice 2 (2026-05-22): no-op-if-unchanged guard. Closes // issue #96 (per-tick CP-write blowup) without removing the // PhysicsEngine.cs L622 seed that step_up depends on. When the @@ -3880,35 +3859,12 @@ public sealed class Transition var oi = ObjectInfo; var ci = CollisionInfo; - // #334 candidate-disposition probe (2026-08-06 — TEMPORARY, strip with - // the physics-probe family). Filtered to the player mover so NPC / - // remote dead-reckoning resolves do not pollute the capture, matching - // PhysicsResolveCapture's filter. The zero-entry query below is - // reported EXPLICITLY: "this cell yielded nothing" is outcome (b) — - // a registration gap — and must appear as data, never as silence. - bool reachProbe = PhysicsDiagnostics.ProbeReachEnabled && oi.IsPlayer; - if (objsInCell.Count == 0) { - if (reachProbe) - PhysicsDiagnostics.LogReachQuery( - oi.SelfEntityId, cellId, sp.StepDown, - inCell: 0, exempt: 0, reached: 0, rejectedReach: 0, - noShape: 0, tested: 0, blocked: 0, - currPos: sp.GlobalCurrCenter[0].Origin); return TransitionState.OK; } - // #42 diagnostic (2026-05-05): identify which static object causes - // the airborne first-frame ~1m push. - bool airborneDiag = !oi.Contact - && Environment.GetEnvironmentVariable("ACDREAM_AIRBORNE_DIAG") == "1"; - Vector3 sphereCheckBefore = sp.CheckPos; - Vector3 checkPos = sp.GlobalSphere[0].Origin; - Vector3 currPos = sp.GlobalCurrCenter[0].Origin; - float sphereRadius = sp.GlobalSphere[0].Radius; - Vector3 movement = checkPos - currPos; // Landblock offsets feed the [resolve-bldg] probe only. engine.TryGetLandblockContext(checkPos.X, checkPos.Y, @@ -3919,38 +3875,8 @@ public sealed class Transition // registry mutations through the same reference mid-iteration. using var nearbyObjs = ShadowEntrySnapshot.Capture(objsInCell); - // #334 probe tallies — see LogReachQuery for what each one decides. - // `rejectedReach` is deliberately still REPORTED and structurally 0 - // since #333 deleted the filter: a run of the acceptance capture whose - // [reach-q] lines read rejectedReach=0 where the pre-fix capture read - // 7,225 rejections is the evidence the filter is gone, and dropping the - // column would make the two captures incomparable. - const int rRejected = 0; - int rExempt = 0, rReached = 0, rNoShape = 0, - rTested = 0, rBlocked = 0; - foreach (ShadowEntry obj in nearbyObjs.Entries) { - // #337 [geom] probe (2026-08-06 — TEMPORARY, strip with the - // physics-probe family). Emitted here, at the TOP of the candidate - // loop, so it covers every object the mover comes near regardless - // of what the exemptions and the reach filter later do with it — - // an object whose collision geometry is absent or displaced must - // be reported even when nothing ever tests it. Once per GfxObj per - // process: the line describes an ASSET, not a moment. - if (obj.CollisionType == ShadowCollisionType.BSP - && PhysicsDiagnostics.ShouldLogGeometry(obj.GfxObjId)) - { - PhysicsDiagnostics.LogGeometryFromAssets( - gfxObjId: obj.GfxObjId, - entityId: obj.EntityId, - flat: engine.DataCache.GetFlatGfxObj(obj.GfxObjId), - visual: engine.DataCache.GetVisualBounds(obj.GfxObjId), - entityWorldPosition: obj.Position, - entityScale: obj.Scale, - registeredRadius: obj.Radius); - } - // Self-skip — fix #42 (2026-05-05). Mirrors retail // CObjCell::find_obj_collisions at acclient_2013_pseudo_c.txt // 308931: `physobj != arg2->object_info.object` rejects the @@ -3965,12 +3891,6 @@ public sealed class Transition // gfxObj=0x02000001 at exactly the entity's own position). if (oi.SelfEntityId != 0 && obj.EntityId == oi.SelfEntityId) { - if (reachProbe) - { - rExempt++; - ProbeReachCandidate(engine, oi, sp, cellId, in obj, - "exempt-self", sphereRadius, movement.Length(), currPos); - } continue; } @@ -3979,12 +3899,6 @@ public sealed class Transition // dispatch; a true result reaches neither branch. if (oi.MissileIgnore(obj.EntityId, obj.State, obj.Flags)) { - if (reachProbe) - { - rExempt++; - ProbeReachCandidate(engine, oi, sp, cellId, in obj, - "exempt-missile", sphereRadius, movement.Length(), currPos); - } continue; } @@ -3993,7 +3907,6 @@ public sealed class Transition // Cell membership IS the broad phase, and the BSP walk's own root // node bounding-sphere test — correctly centred, unlike the deleted // filter — is the early-out that made this one unnecessary. - if (reachProbe) rReached++; // Commit C 2026-04-29 — retail exemption block at the top of // CPhysicsObj::FindObjCollisions @@ -4005,12 +3918,6 @@ public sealed class Transition // so this is a cheap fall-through for them. if (CollisionExemption.ShouldSkip(obj.State, obj.Flags, ObjectInfo.State)) { - if (reachProbe) - { - rExempt++; - ProbeReachCandidate(engine, oi, sp, cellId, in obj, - "exempt-rule", sphereRadius, movement.Length(), currPos); - } continue; } @@ -4040,12 +3947,6 @@ public sealed class Transition if (etherealForTest && sp.StepDown) { // retail pc:276799 — ethereal target not tested in step-down - if (reachProbe) - { - rExempt++; - ProbeReachCandidate(engine, oi, sp, cellId, in obj, - "exempt-ethereal-stepdown", sphereRadius, movement.Length(), currPos); - } continue; } sp.ObstructionEthereal = etherealForTest; @@ -4096,15 +3997,6 @@ public sealed class Transition // clear (pc:276989) fires after shape tests; we clear early here to // leave the flag clean for the next iteration. sp.ObstructionEthereal = false; - // #334 outcome (c): the entry IS a candidate, the reach - // filter DID admit it, and it still contributes nothing - // because no usable physics BSP resolved for its GfxObj. - if (reachProbe) - { - rNoShape++; - ProbeReachCandidate(engine, oi, sp, cellId, in obj, - "no-shape", sphereRadius, movement.Length(), currPos); - } continue; } @@ -4172,12 +4064,6 @@ public sealed class Transition Console.WriteLine(System.FormattableString.Invariant( $"[sph-skip-bsp] obj=0x{obj.EntityId:X8} state=0x{obj.State:X8} — HAS_PHYSICS_BSP_PS dispatches BSP-only")); } - if (reachProbe) - { - rExempt++; - ProbeReachCandidate(engine, oi, sp, cellId, in obj, - "bsp-only-skip", sphereRadius, movement.Length(), currPos); - } continue; } @@ -4221,12 +4107,6 @@ public sealed class Transition Console.WriteLine(System.FormattableString.Invariant( $"[cyl-skip-bsp] obj=0x{obj.EntityId:X8} state=0x{obj.State:X8} — HAS_PHYSICS_BSP_PS dispatches BSP-only")); } - if (reachProbe) - { - rExempt++; - ProbeReachCandidate(engine, oi, sp, cellId, in obj, - "bsp-only-skip", sphereRadius, movement.Length(), currPos); - } continue; } @@ -4390,138 +4270,15 @@ public sealed class Transition // per-object test body before the outer loop continues. sp.ObstructionEthereal = false; - // #334: this candidate actually reached a shape test. `result` is - // post-Layer-2, i.e. what the query will really act on. - if (reachProbe) - { - rTested++; - if (result != TransitionState.OK) rBlocked++; - ProbeReachCandidate(engine, oi, sp, cellId, in obj, - result switch - { - TransitionState.OK => "tested-ok", - TransitionState.Collided => "tested-collided", - TransitionState.Adjusted => "tested-adjusted", - TransitionState.Slid => "tested-slid", - _ => "tested-invalid", - }, - sphereRadius, movement.Length(), currPos); - } - if (result != TransitionState.OK) { - if (airborneDiag) - { - var sphereCheckAfter = sp.CheckPos; - var d = sphereCheckAfter - sphereCheckBefore; - Console.WriteLine( - $"[SWEEP-OBJ] type={obj.CollisionType} gfxObj=0x{obj.GfxObjId:X8} " + - $"objPos=({obj.Position.X:F3},{obj.Position.Y:F3},{obj.Position.Z:F3}) " + - $"objR={obj.Radius:F3} cylH={obj.CylHeight:F3} " + - $"state={result} pushDelta=({d.X:F3},{d.Y:F3},{d.Z:F3})"); - } - // #334 (TEMPORARY): the early exit is a real query outcome, so - // it must be summarised too — otherwise the summary would - // under-report exactly the queries where something DID block, - // and "blocked" is the control that proves the probe can see a - // working collision as well as a missing one. - if (reachProbe) - PhysicsDiagnostics.LogReachQuery( - oi.SelfEntityId, cellId, sp.StepDown, - nearbyObjs.Entries.Length, rExempt, rReached, - rRejected, rNoShape, rTested, rBlocked, currPos); return result; } } - if (reachProbe) - PhysicsDiagnostics.LogReachQuery( - oi.SelfEntityId, cellId, sp.StepDown, - nearbyObjs.Entries.Length, rExempt, rReached, - rRejected, rNoShape, rTested, rBlocked, currPos); - return TransitionState.OK; } - /// - /// #334 candidate-disposition probe helper (2026-08-06 — TEMPORARY, strip - /// with the physics-probe family). Static, and takes everything by - /// parameter, so it introduces no closure display class into - /// — Slice I1's 0 B/resolve budget - /// must hold with the probe compiled in and switched off. - /// - /// - /// The target's physics-BSP ROOT sphere is resolved through the SAME - /// production accessor registration used - /// (GetFlatGfxObj(id).PhysicsBsp's root node, per - /// LiveEntityCollisionBuilder and ShadowShapeBuilder), so the - /// probe cannot report geometry that differs from what the registry - /// actually emitted. AP-156's lesson was exactly that: one resolver. - /// - /// - private static void ProbeReachCandidate( - PhysicsEngine engine, - ObjectInfo oi, - SpherePath sp, - uint cellId, - in ShadowEntry obj, - string disposition, - float sphereRadius, - float movementLen, - Vector3 currPos) - { - bool xyOnly = obj.CollisionType == ShadowCollisionType.Cylinder; - - Vector3 dOrigin = currPos - obj.Position; - float distOrigin = xyOnly - ? MathF.Sqrt(dOrigin.X * dOrigin.X + dOrigin.Y * dOrigin.Y) - : dOrigin.Length(); - - // World-space offset from the part ORIGIN (what the DELETED filter - // measured against) to the BSP root sphere CENTRE (what it should have - // measured against). Both distances are still emitted after #333: the - // pair is what proved the diagnosis, and it stays comparable across the - // pre-fix and post-fix captures. Zero for non-BSP shapes, whose - // Position already IS their centre. - Vector3 bspCentreOffset = Vector3.Zero; - if (obj.CollisionType == ShadowCollisionType.BSP) - { - var flatBsp = engine.DataCache?.GetFlatGfxObj(obj.GfxObjId)?.PhysicsBsp; - if (flatBsp is { RootIndex: >= 0 }) - { - bspCentreOffset = Vector3.Transform( - flatBsp.Nodes[flatBsp.RootIndex].BoundingSphere.Origin * obj.Scale, - obj.Rotation); - } - } - - Vector3 dCentre = currPos - (obj.Position + bspCentreOffset); - float distCentre = xyOnly - ? MathF.Sqrt(dCentre.X * dCentre.X + dCentre.Y * dCentre.Y) - : dCentre.Length(); - - float budget = sphereRadius + obj.Radius + movementLen + 2f; - - PhysicsDiagnostics.LogReachCandidate( - moverId: oi.SelfEntityId, - entityId: obj.EntityId, - gfxObjId: obj.GfxObjId, - cellId: cellId, - shape: obj.CollisionType, - disposition: disposition, - stepDown: sp.StepDown, - distOrigin: distOrigin, - distCenter: distCentre, - objRadius: obj.Radius, - sphereRadius: sphereRadius, - movementLen: movementLen, - budget: budget, - centerBudget: budget - 2f, - objPos: obj.Position, - bspCentreOffset: bspCentreOffset, - currPos: currPos); - } - /// /// BR-7 / A6.P4 (2026-06-11). The retail BUILDING collision channel — /// CSortCell::find_collisions (Ghidra 0x005340a0): an outdoor diff --git a/src/AcDream.Core/Physics/WalkMissDiagnostic.cs b/src/AcDream.Core/Physics/WalkMissDiagnostic.cs deleted file mode 100644 index 35a7dcfa..00000000 --- a/src/AcDream.Core/Physics/WalkMissDiagnostic.cs +++ /dev/null @@ -1,173 +0,0 @@ -using System.Collections.Generic; -using System.Numerics; - -namespace AcDream.Core.Physics; - -/// -/// ISSUES #83 H-disambiguation spike (2026-05-21). Pure-function -/// aggregator over a dict — picks -/// the nearest walkable-eligible polygon to a given foot position -/// (cell-local space) and reports XY-containment + vertical gap so -/// the [walk-miss] emission site can disambiguate H1/H2/H3 -/// without re-walking the dictionary itself. -/// -/// -/// Also enumerates walkable polygons for the one-shot -/// [floor-polys] dump at cell-cache time. -/// -/// -/// -/// Spec: docs/superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md. -/// -/// -public static class WalkMissDiagnostic -{ - public readonly struct AggregateResult - { - public bool Found { get; init; } - public ushort PolyId { get; init; } - public bool ContainsFootXY { get; init; } - public float Dz { get; init; } - public float NormalZ { get; init; } - } - - public readonly struct WalkableEntry - { - public ushort PolyId { get; init; } - public float NormalZ { get; init; } - public Vector3 BboxMin { get; init; } - public Vector3 BboxMax { get; init; } - public float PlaneZAtBboxCenter { get; init; } - } - - /// - /// Walks , considering only polygons - /// whose plane normal Z is at least - /// (walkable slope). Selection rule: - /// - /// Polygons whose local-XY bounding box contains - /// 's XY are preferred. Among them, - /// the one with smallest |dz| wins. - /// If no poly contains the foot XY, the poly - /// with smallest |dz| across all walkable polys wins, - /// and is false. - /// - /// - public static AggregateResult AggregateNearestWalkable( - IReadOnlyDictionary resolved, - Vector3 footLocal, - float floorZ) - { - bool bestFound = false; - bool bestContainsFootXY = false; - ushort bestPolyId = 0; - float bestAbsDz = float.MaxValue; - float bestSignedDz = 0f; - float bestNormalZ = 0f; - - foreach (var kvp in resolved) - { - var poly = kvp.Value; - if (poly.Plane.Normal.Z < floorZ) continue; - if (poly.Vertices.Length < 3) continue; - - // Local-XY bounding box. - float minX = float.MaxValue, minY = float.MaxValue; - float maxX = float.MinValue, maxY = float.MinValue; - for (int i = 0; i < poly.Vertices.Length; i++) - { - var v = poly.Vertices[i]; - if (v.X < minX) minX = v.X; - if (v.Y < minY) minY = v.Y; - if (v.X > maxX) maxX = v.X; - if (v.Y > maxY) maxY = v.Y; - } - bool containsFootXY = - footLocal.X >= minX && footLocal.X <= maxX && - footLocal.Y >= minY && footLocal.Y <= maxY; - - // Signed vertical gap from foot to the polygon's plane at - // the foot's XY: plane.D + n.x*X + n.y*Y + n.z*Z = 0 - // => planeZ = -(D + n.x*X + n.y*Y) / n.z - // => dz = footZ - planeZ - float planeZ = -(poly.Plane.D - + poly.Plane.Normal.X * footLocal.X - + poly.Plane.Normal.Y * footLocal.Y) - / poly.Plane.Normal.Z; - float signedDz = footLocal.Z - planeZ; - float absDz = MathF.Abs(signedDz); - - // Preference: prefer XY-containing polys. Among the - // preferred set, smallest |dz| wins. - bool preferOver = !bestFound - || (containsFootXY && !bestContainsFootXY) - || (containsFootXY == bestContainsFootXY && absDz < bestAbsDz); - - if (preferOver) - { - bestFound = true; - bestContainsFootXY = containsFootXY; - bestPolyId = kvp.Key; - bestAbsDz = absDz; - bestSignedDz = signedDz; - bestNormalZ = poly.Plane.Normal.Z; - } - } - - return new AggregateResult - { - Found = bestFound, - PolyId = bestPolyId, - ContainsFootXY = bestContainsFootXY, - Dz = bestSignedDz, - NormalZ = bestNormalZ, - }; - } - - /// - /// Enumerates walkable-eligible polygons (normal Z >= floorZ) - /// with their local-XY bounding boxes and plane Z at the bbox - /// center. Used by the one-shot [floor-polys] cell-load - /// dump. - /// - public static IEnumerable EnumerateWalkable( - IReadOnlyDictionary resolved, - float floorZ) - { - foreach (var kvp in resolved) - { - var poly = kvp.Value; - if (poly.Plane.Normal.Z < floorZ) continue; - if (poly.Vertices.Length < 3) continue; - - float minX = float.MaxValue, minY = float.MaxValue, minZ = float.MaxValue; - float maxX = float.MinValue, maxY = float.MinValue, maxZ = float.MinValue; - for (int i = 0; i < poly.Vertices.Length; i++) - { - var v = poly.Vertices[i]; - if (v.X < minX) minX = v.X; - if (v.Y < minY) minY = v.Y; - if (v.Z < minZ) minZ = v.Z; - if (v.X > maxX) maxX = v.X; - if (v.Y > maxY) maxY = v.Y; - if (v.Z > maxZ) maxZ = v.Z; - } - - float cx = (minX + maxX) * 0.5f; - float cy = (minY + maxY) * 0.5f; - float planeZAtCenter = -(poly.Plane.D - + poly.Plane.Normal.X * cx - + poly.Plane.Normal.Y * cy) - / poly.Plane.Normal.Z; - - yield return new WalkableEntry - { - PolyId = kvp.Key, - NormalZ = poly.Plane.Normal.Z, - BboxMin = new Vector3(minX, minY, minZ), - BboxMax = new Vector3(maxX, maxY, maxZ), - PlaneZAtBboxCenter = planeZAtCenter, - }; - } - } -} diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index 578f9246..634627f4 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -99,33 +99,6 @@ public static class RenderingDiagnostics public static bool ProbeVisibilityEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_VIS") == "1"; - /// - /// #119-residual viewer/flood capture (2026-06-11): one [viewer] - /// line per CHANGE of (root cell, flood size, OutsideView poly count, - /// player cell), with the projection EYE at mm precision on every line — - /// the capture half of the tower-ascent capture→replay loop - /// (TowerAscentReplayTests replays the captured pairs deterministically). - /// Light: silent while the visibility state is stable; a tower climb - /// emits a few dozen lines. Initial state from - /// ACDREAM_PROBE_VIEWER=1. - /// - public static bool ProbeViewerEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_VIEWER") == "1"; - - /// - /// #131 (2026-06-12) outside-stage dynamics probe. When true, the renderer - /// emits one [outstage] line per CHANGE of the outside-stage - /// routing + per-slice cone verdict set under an interior root (which - /// outdoor dynamics were routed to the landscape slice, which survived the - /// slice viewcone), and GameWindow emits one [outstage-pt] line per - /// change of the slice Scene-particle id set + matched-emitter count. - /// Built for the portal-swirl-missing-through-doorway capture. Light: - /// silent while the set is stable. Initial state from - /// ACDREAM_PROBE_OUTSTAGE=1. - /// - public static bool ProbeOutStageEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_OUTSTAGE") == "1"; - /// /// Phase U.4c (2026-05-31) flap-convergence probe. When true, the portal /// visibility pass emits, EVERY frame the camera root is an indoor cell, a @@ -144,21 +117,6 @@ public static class RenderingDiagnostics public static bool ProbeFlapEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_FLAP") == "1"; - /// - /// Issue #78 (2026-05-31) cell-shell render probe. When true, - /// EnvCellRenderer.Render emits one [shell] line per opaque-pass - /// call: per visible (filtered) cell — is it present in the prepared snapshot, - /// how many gfxObjs + instances, and per-gfxObj batch count / index count / - /// translucent / zero-bindless-handle (missing texture) — plus the pass totals. - /// This directly answers WHY interior walls/ceilings don't appear: no geometry - /// prepared for the cell (cell absent / 0 instances), drawn-but-invisible - /// (zeroHandle / translucent against the clear color), or prepared+drawn (so the - /// fault is elsewhere — depth/occlusion). Throwaway apparatus — strip once the - /// indoor-enclosure render is fixed. Initial state from ACDREAM_PROBE_SHELL=1. - /// - public static bool ProbeShellEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_SHELL") == "1"; - /// /// Flap root-cause apparatus (2026-06-07). When true, the indoor render path emits ONE /// [pv-input] line per frame with the EXACT PortalVisibilityBuilder.Build inputs at HIGH @@ -202,21 +160,6 @@ public static class RenderingDiagnostics public static bool ProbeClipRouteEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_CLIPROUTE") == "1"; - /// - /// #105 white-indoor-textures apparatus (2026-06-10). When true, WbMeshAdapter.Tick - /// emits one [tex-flush] line whenever the staged-texture-update picture changes: - /// pending layer updates across all shared atlases BEFORE and AFTER the per-frame - /// ObjectMeshManager.GenerateMipmaps() flush, plus arrays-with-pending / total-array - /// counts. The broken contract this pins: TextureAtlasManager.AddTexture only STAGES - /// pixel data (PBO + pending list); without the per-frame flush (WB GameScene.cs:975) the - /// data never reaches the GL texture and the batch samples undefined content behind a valid - /// bindless handle — the classic white walls. A healthy run shows after=0 on every - /// line; a stuck before==after>0 at standstill is the #105 mechanism live. - /// Initial state from ACDREAM_PROBE_TEXFLUSH=1. - /// - public static bool ProbeTexFlushEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_TEXFLUSH") == "1"; - /// /// Bounded-propagation port apparatus (2026-06-08). When true, PortalVisibilityBuilder.Build emits /// one [portal-churn] summary line per call: per-cell pop count (re-pops = churn), total re-enqueues, @@ -228,52 +171,6 @@ public static class RenderingDiagnostics public static bool ProbePortalChurnEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_PORTAL_CHURN") == "1"; - /// - /// BR-2 phantom-site probe (2026-06-11; plan - /// docs/plans/2026-06-11-building-render-port-plan.md §BR-2 first - /// task). The BR-1 pre-check proved the #113 phantom residual cannot be - /// GfxObj portal fills (never extracted); the surviving suspects are - /// cell-side. When true, RetailPViewRenderer emits, print-on-change - /// per cell: [phantom-shell] — per shell-pass cell, the clip-enable - /// state and each drawn slice's slot + plane count, flagging the pass-all - /// cases (NoClipSlice fallback for slot-less cells; assembler slot-0 - /// scissor fallback) — and [phantom-objs] — per object-list cell, - /// the entity-bucket size drawn unclipped/un-viewcone'd. Reproducing the - /// phantom with this on pins which mechanism draws it (shells → BR-2/BR-3; - /// statics → BR-5). Throwaway apparatus — strip when the phantom closes. - /// Initial state from ACDREAM_PROBE_PHANTOM=1. - /// - public static bool ProbePhantomEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_PHANTOM") == "1"; - - /// - /// #133 A7 (2026-06-13) dungeon-lighting objective probe. When true, - /// the per-frame scene-lighting build emits ONE [light] line - /// roughly every second (wall-clock rate-limited like WB-DIAG) via - /// : - /// - /// [light] insideCell=<bool> ambient=(r,g,b) sun=<intensity> - /// registeredLights=<N> activeLights=<uCellAmbient.w> playerCell=0x<id> - /// - /// This is the self-verification signal for the dungeon-dim question: - /// - /// insideCell=true ambient=(0.20,0.20,0.20) sun=0 - /// confirms the indoor branch fired (retail flat ambient, sun killed). - /// registeredLights is the count of dat-baked - /// point/spot lights (Setup.Lights) registered with the - /// LightManager — if this is 0 in a dungeon, the cell's static - /// objects carry no baked torches (so the only illumination IS the - /// 0.2 ambient → dim). - /// activeLights is uCellAmbient.w — the - /// shader's active-slot count, which INCLUDES the (zeroed) sun slot - /// indoors. So activeLights=1 registeredLights=0 = "only the dead - /// sun slot, no torches in range". - /// - /// Output-only, inert when off. Initial state from ACDREAM_PROBE_LIGHT=1. - /// - public static bool ProbeLightEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIGHT") == "1"; - /// /// A7.L1 (2026-07-06) light-pool SET-COMPOSITION probe — the apparatus the /// [light] counts could not provide (the #176/#177 discriminator: the bug @@ -496,94 +393,8 @@ public static class RenderingDiagnostics /// internal static void ResetVisibilityProbeForTests() => _lastVisRootCellId = 0; - // Wall-clock rate-limit gate for EmitLight. Ticks (100 ns) is plenty — - // we only need ~1 Hz and avoid a Stopwatch allocation/field. Sentinel 0 - // = "never emitted" so the first call always fires. - private static long _lastLightEmitTicks; private const long LightEmitIntervalTicks = 10_000_000; // 1 s in 100-ns ticks - /// - /// #133 A7 — emit ONE rate-limited [light] line describing the - /// current scene-lighting state, followed (when - /// is supplied) by up to three [light-detail] lines for the nearest - /// ACTIVE point/spot lights. Cheap no-op when - /// is false; otherwise fires at most - /// once per second. Pull the values from the spot where - /// GameWindow.UpdateSunFromSky set Lighting.CurrentAmbient - /// / Lighting.Sun and where SceneLightingUbo.Build computed - /// the active-slot count. - /// - /// The [light-detail] lines are the answer to the "candle-spotlight" - /// question — they expose each torch's REAL dat-derived runtime values - /// (range= Falloff metres, intensity=, cone= radians, - /// color=, distToViewer=) so it is visible in launch.log - /// whether dungeon torches are tiny-range points or wide cones and at what - /// intensity — without a screenshot: - /// - /// [light-detail] kind=Point range=<Falloff m> intensity=<I> cone=<rad> color=(r,g,b) distToViewer=<m> - /// - /// - /// - /// The playerInsideCell value driving the indoor branch. - /// Cell ambient red (xyz of uCellAmbient). - /// Cell ambient green. - /// Cell ambient blue. - /// The sun LightSource.Intensity (0 indoors). - /// Total point/spot lights registered with the LightManager. - /// uCellAmbient.w — shader active-slot count (includes the zeroed sun slot indoors). - /// The player's current cell id (0 if unresolved → outside). - /// The ticked LightManager (its Active list, sorted nearest-first by the - /// just-completed Tick). When non-null, drives the [light-detail] lines. Optional so existing call - /// sites / tests that only want the aggregate line keep compiling. - public static void EmitLight(bool insideCell, - float ambientR, float ambientG, float ambientB, - float sunIntensity, - int registeredLights, - int activeLights, - uint playerCellId, - AcDream.Core.Lighting.LightManager? lights = null) - { - if (!ProbeLightEnabled) return; - - long now = DateTime.UtcNow.Ticks; - if (_lastLightEmitTicks != 0 && (now - _lastLightEmitTicks) < LightEmitIntervalTicks) - return; - _lastLightEmitTicks = now; - - var ci = System.Globalization.CultureInfo.InvariantCulture; - Console.WriteLine(string.Format(ci, - "[light] insideCell={0} ambient=({1:0.###},{2:0.###},{3:0.###}) sun={4:0.###} registeredLights={5} activeLights={6} playerCell=0x{7:X8}", - insideCell, ambientR, ambientG, ambientB, sunIntensity, - registeredLights, activeLights, playerCellId)); - - // #133 A7 (2026-06-13) — per-light detail for the "spotlight bubble" - // question. Dump the actual runtime dat-derived values of the nearest - // ~3 ACTIVE point/spot lights so the real Falloff/Intensity/ConeAngle - // are visible in launch.log (are torch ranges 1m or 10m? points or - // spots? what intensity?). The sun (Directional, slot 0) is skipped — - // it carries no Range/cone meaning. DistSq is already cached by - // LightManager.Tick this frame, so the active list is sorted nearest- - // first; we just take the first few non-directional entries. - if (lights is null) return; - var active = lights.Active; - int shown = 0; - const int MaxDetail = 3; - for (int i = 0; i < active.Length && shown < MaxDetail; i++) - { - var ls = active[i]; - if (ls is null) continue; - if (ls.Kind == AcDream.Core.Lighting.LightKind.Directional) continue; - - float dist = ls.DistSq >= 0f ? MathF.Sqrt(ls.DistSq) : 0f; - Console.WriteLine(string.Format(ci, - "[light-detail] kind={0} range={1:0.###} intensity={2:0.###} cone={3:0.####} color=({4:0.###},{5:0.###},{6:0.###}) distToViewer={7:0.###} owner=0x{8:X8} cell=0x{9:X8} dyn={10}", - ls.Kind, ls.Range, ls.Intensity, ls.ConeAngle, - ls.ColorLinear.X, ls.ColorLinear.Y, ls.ColorLinear.Z, dist, - ls.OwnerId, ls.CellId, ls.IsDynamic ? 1 : 0)); - shown++; - } - } - // Wall-clock rate-limit gate for EmitIndoorLight (shares the 1 s interval). private static long _lastIndoorLightEmitTicks; @@ -694,27 +505,6 @@ public static class RenderingDiagnostics /// public static bool IsEnvCellId(ulong id) => (id & 0xFFFFu) >= 0x0100u; - /// - /// #119 tower-staircase decisive probe (2026-06-11). Comma-separated - /// Setup / GfxObj source ids (hex, optional 0x prefix) from - /// ACDREAM_DUMP_ENTITY. Any WorldEntity whose - /// SourceGfxObjOrSetupId is in this set emits: - /// (a) a [dump-entity] HYDRATE dump at MeshRef construction time - /// (GameWindow.BuildInteriorEntitiesForStreaming) — per-part - /// placement-frame translations + dropped-part accounting — discriminating - /// hydration-time corruption (H-A: SetupMesh.Flatten identity fallback / - /// silent gfx-null part drops under degraded dat reads); - /// (b) a [dump-entity] DRAW dump in WbDrawDispatcher at first - /// draw — live MeshRefs translations + Tier-1 classification cache state — - /// re-emitted compactly whenever that state changes (H-B: stale/partial - /// cached batch set); and - /// (c) rate-limited [dump-entity] WALK-REJECT lines when the - /// dispatcher's walk filters the entity out (absence-of-draw attribution). - /// Empty set = probe off; every call site early-outs on Count == 0. - /// - public static IReadOnlySet DumpEntitySourceIds { get; } = - ParseDumpEntityIds(Environment.GetEnvironmentVariable("ACDREAM_DUMP_ENTITY")); - /// /// Parse the ACDREAM_DUMP_ENTITY value: comma-separated hex ids, /// optional 0x prefix, whitespace tolerated, malformed segments ignored @@ -794,52 +584,4 @@ public static class RenderingDiagnostics /// public static string? FrameHistoryPath { get; } = Environment.GetEnvironmentVariable("ACDREAM_FRAME_HISTORY"); - - // ── #337 collision-mesh wireframe (2026-08-06 — TEMPORARY) ────────────── - // - // The F2 collision overlay already existed, but for a BSP object it drew a - // proxy cylinder sized from the REGISTERED BROADPHASE RADIUS. That shows - // where the collision system thinks the object roughly is; it cannot show - // where the collision SURFACES are, which is the only thing that answers - // "is the collision geometry where the visual geometry is". The knobs - // below turn F2 into the real answer: the actual physics-BSP polygon - // edges, in world space, next to the same object's visual mesh box. - // - // Off by default, so F2 keeps its old cheap behaviour for anyone who wants - // it and this costs nothing until asked for. - - /// - /// When true, the F2 collision overlay draws each nearby object's REAL - /// physics-BSP polygon edges (cyan) and, beside them, the same object's - /// visual mesh bounding box (magenta), plus the terrain triangle under the - /// player (yellow). Any separation between the cyan surfaces and the - /// object you can see is the "collision is not where the visual is" - /// defect, read directly off the screen instead of inferred from a log. - /// Initial state from ACDREAM_WIRE_MESH=1. - /// TEMPORARY — strip with the #337 probe family. - /// - public static bool CollisionMeshWireframeEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_WIRE_MESH") == "1"; - - /// - /// Radius in metres around the player within which - /// resolves polygon geometry. - /// A whole landblock of rock is far more geometry than a line list wants; - /// 30 m covers everything you can wedge against. Override with - /// ACDREAM_WIRE_RADIUS=<metres>. - /// - public static float CollisionMeshWireframeRadius { get; set; } = - ParsePositiveFloat( - Environment.GetEnvironmentVariable("ACDREAM_WIRE_RADIUS"), - fallback: 30f); - - private static float ParsePositiveFloat(string? raw, float fallback) - => float.TryParse( - raw, - System.Globalization.NumberStyles.Float, - System.Globalization.CultureInfo.InvariantCulture, - out float value) - && value > 0f - ? value - : fallback; } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs index 19f30dfb..727ac4db 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs @@ -215,14 +215,6 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable controller.StepUpHeight = command.Physics.StepUpHeight; controller.StepDownHeight = command.Physics.StepDownHeight; - // #338 (TEMPORARY): second of three readings. If `prepare` printed the - // authored pair and this line does not appear, the command is built - // and never consumed on the local player's path. - PhysicsDiagnostics.LogStepHeights( - "publish", - command.Physics.StepUpHeight, - command.Physics.StepDownHeight, - $"localEntityId={controller.LocalEntityId} scale={command.Physics.Scale:F3}"); controller.SphereList = command.Physics.Spheres; controller.ObjectScale = command.Physics.Scale; controller.PreparePositionForCommit( @@ -320,35 +312,16 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable // the PhysicsDiagnostics owner exactly as before. handleUpdateTarget: info => { - if (PhysicsDiagnostics.ProbeAutoWalkEnabled) - { - Console.WriteLine( - $"[autowalk-target] object=0x{info.ObjectId:X8} " - + $"status={info.Status} context={info.ContextId} " - + $"target=({info.TargetPosition.Frame.Origin.X:F2}," - + $"{info.TargetPosition.Frame.Origin.Y:F2}," - + $"{info.TargetPosition.Frame.Origin.Z:F2})"); - } movement.HandleUpdateTarget(info); }, interruptCurrentMovement: () => { - if (PhysicsDiagnostics.ProbeAutoWalkEnabled - && movement.IsMovingTo()) - { - Console.WriteLine("[autowalk-end] reason=interrupt"); - } movement.CancelMoveTo(WeenieError.ActionCancelled); }); movement.MakeMoveToManager(); motion.UnstickFromObject = physicsHost.PositionManager.UnStick; motion.InterruptCurrentMovement = () => { - if (PhysicsDiagnostics.ProbeAutoWalkEnabled - && movement.IsMovingTo()) - { - Console.WriteLine("[autowalk-end] reason=interrupt"); - } movement.CancelMoveTo(WeenieError.ActionCancelled); }; controller.PositionManager = physicsHost.PositionManager; diff --git a/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs b/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs index 394924ab..59f4338d 100644 --- a/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs +++ b/src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs @@ -551,34 +551,6 @@ internal sealed class RuntimeRemotePhysicsUpdater if (!previousOnWalkable && finalOnWalkable) { - // Bug A investigation (2026-08-04, docs/ISSUES.md #32): - // capture the exact state HitGround is about to act on — - // see PhysicsDiagnostics.LogRemoteLanding for the field - // list and PhysicsDiagnostics.ProbeRemoteLandingEnabled - // for the discriminator table. TEMPORARY — strip once - // the live-test run has landed. - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeRemoteLandingEnabled) - { - AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLanding( - site: "per-tick", - guid: serverGuid, - airborneBefore: true, - gravitySet: rm.Body.HasGravity, - contact: rm.Body.InContact, - onWalkable: rm.Body.OnWalkable, - hasDefaultSink: rm.Motion.DefaultSink is not null, - resolveIsOnGround: resolveResult.IsOnGround, - sequencerStyle: sequencer?.CurrentStyle ?? 0, - sequencerMotion: sequencer?.CurrentMotion ?? 0); - if (!rm.Body.HasGravity) - { - AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLandingGateNoOp( - "per-tick", serverGuid); - } - AcDream.Core.Physics.PhysicsDiagnostics - .BeginRemoteLandingDispatchCapture(); - } - // #161: HitGround MUST run with the Gravity state bit // still set — CMotionInterp::HitGround (0x00528AC0) // gates on state & 0x400. Bug B deleted the clear that @@ -591,22 +563,6 @@ internal sealed class RuntimeRemotePhysicsUpdater // (MovementManager::HitGround 0x00524300). rm.Movement.HitGround(); - // Bug A investigation (2026-08-04) — the OUTCOME half of - // the probe above, emitted before the ownership re-check - // below can return so the two lines always pair. See - // PhysicsDiagnostics.LogRemoteLandingAfter and - // docs/research/2026-08-04-bug-a-h3-scheduler-diagnosis.md - // §6.1 for the three-way decision table. TEMPORARY. - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeRemoteLandingEnabled) - { - AcDream.Core.Physics.PhysicsDiagnostics.LogRemoteLandingAfter( - site: "per-tick", - guid: serverGuid, - hitGroundInvoked: true, - sequencerStyle: sequencer?.CurrentStyle ?? 0, - sequencerMotion: sequencer?.CurrentMotion ?? 0, - forwardCommand: rm.Motion.InterpretedState.ForwardCommand); - } if (!IsCurrentOwner( record, rm, diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs index 648e3d02..fc6aef6e 100644 --- a/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs +++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionMoverPreparation.cs @@ -180,15 +180,6 @@ internal static class RuntimeSetPositionMoverPreparer float stepUp = setup is not null ? setup.StepUpHeight * scale : 0f; float stepDown = setup is not null ? setup.StepDownHeight * scale : 0f; - // #338 (TEMPORARY): first of three readings along the chain. Prints - // the raw authored pair alongside the scaled one, so a surprise here - // separates "wrong Setup" from "wrong scale" without a second run. - PhysicsDiagnostics.LogStepHeights( - "prepare", stepUp, stepDown, - setup is not null - ? $"authored=({setup.StepUpHeight:F3},{setup.StepDownHeight:F3}) scale={scale:F3}" - : "setup=NULL (retail dummy path, exact zero steps)"); - EntityCollisionFlags collisionFlags = EntityCollisionFlagsExt.FromPwdBitfield( record.Snapshot.ObjectDescriptionFlags ?? 0u); diff --git a/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs b/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs index 32f018dd..6297df33 100644 --- a/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs @@ -51,8 +51,7 @@ public sealed class LaunchOptionsDocumentationTests ["src/AcDream.App/Rendering/Sky/SkyRenderer.cs"] = 1, ["src/AcDream.App/Rendering/TextureCache.cs"] = 1, ["src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs"] = 2, - ["src/AcDream.Core/Physics/PhysicsEngine.cs"] = 1, - ["src/AcDream.Core/Physics/TransitionTypes.cs"] = 3, + ["src/AcDream.Core/Physics/TransitionTypes.cs"] = 2, ["src/AcDream.Core/Vfx/PhysicsScriptRunner.cs"] = 1, ["src/AcDream.Core/World/SkyDescLoader.cs"] = 2, ["src/AcDream.Core.Net/GameEventWiring.cs"] = 1, diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs index 5cb62a18..f00a019a 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs @@ -32,7 +32,6 @@ public sealed class RetailPViewPassExecutorTests "landscape-early", "terrain-clip", "clear-routing", - "outstage-routing", "landscape-late", "landscape-alpha", "indoor-routing", @@ -126,7 +125,6 @@ public sealed class RetailPViewPassExecutorTests Assert.Contains("entity-bucket", executor.Operations); Assert.Contains("cell-particles", executor.Operations); Assert.Contains("dynamics-particles", executor.Operations); - Assert.Contains("phantom-objects", executor.Operations); } [Fact] @@ -591,18 +589,6 @@ public sealed class RetailPViewPassExecutorTests ClipFrameAssembly clipAssembly, ClipViewSlice slice, int sliceIndex) => Operations.Add("clip-probe"); - public void EmitOutStageOwner( - WorldEntity entity, - Vector3 sphereCenter, - float sphereRadius, - int sliceIndex, - bool passed) => Operations.Add("outstage-owner"); - public void EmitOutStageRouting( - int sliceIndex, - IReadOnlyList entities, - ViewconeCuller viewcone) => Operations.Add("outstage-routing"); - public void EmitPhantomObjects(uint cellId, int survivorCount) => - Operations.Add("phantom-objects"); public void DrawLandscapeSlice( RetailPViewFrameInput frame, RetailPViewLandscapeSliceContext context) diff --git a/tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs index 5c2b2a41..298d6c47 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldRenderDiagnosticsTests.cs @@ -145,23 +145,6 @@ public sealed class WorldRenderDiagnosticsTests changed => Assert.StartsWith("[render-sig] frame=3 stable=1 branch=pview", changed)); } - [Fact] - public void OutStageParticleProbe_IsDisabledWithoutEnumerationAndSuppressesDuplicates() - { - var log = new RecordingLog(); - var diagnostics = new WorldRenderDiagnostics(new RecordingGlStateReader(), log); - var particles = new ParticleSystem(new EmitterDescRegistry()); - - diagnostics.EmitOutStageParticles(false, particles, new HashSet()); - diagnostics.EmitOutStageParticles(true, particles, new HashSet()); - diagnostics.EmitOutStageParticles(true, particles, new HashSet()); - - Assert.Single(log.Messages); - Assert.Equal( - "[outstage-pt] ids=0 attachedEmitters=0 matched=0 unattached=0 matchedIds=[] unmatchedIds=[]", - log.Messages[0]); - } - private static RenderGlStateSnapshot State(int depthFunction) => new( DepthTest: true, DepthWrite: true, diff --git a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs index e00587f2..62b92bdf 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs @@ -311,7 +311,6 @@ public sealed class WorldRenderFrameBuilderTests typeof(EnvCellRenderer), typeof(SceneLightingUbo), typeof(SceneLightingUboBinding), - typeof(RenderingDiagnostics), ]; string[] methodOrder = [ @@ -323,7 +322,6 @@ public sealed class WorldRenderFrameBuilderTests nameof(EnvCellRenderer.SetPointSnapshot), nameof(SceneLightingUbo.Build), nameof(SceneLightingUboBinding.Upload), - nameof(RenderingDiagnostics.EmitLight), ]; AssertCompiledCallOrder(calls, ownerOrder, methodOrder); diff --git a/tests/AcDream.Core.Tests/Physics/SupportProbeClassifierTests.cs b/tests/AcDream.Core.Tests/Physics/SupportProbeClassifierTests.cs deleted file mode 100644 index 742164b1..00000000 --- a/tests/AcDream.Core.Tests/Physics/SupportProbeClassifierTests.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System.Numerics; -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -/// -/// #337 (2026-08-06 — TEMPORARY, delete with the [support] probe). -/// -/// -/// The [support] line's whole value is its support= verdict: -/// terrain, an object surface, or nothing. If that classifier is wrong, a -/// capture does not merely fail to answer — it answers CONFIDENTLY WRONG, and -/// this campaign has already spent two diagnoses on confident wrong answers. -/// These cover the decision boundaries directly, so the live capture can be -/// read at face value. -/// -/// -public sealed class SupportProbeClassifierTests -{ - private const float FlatNormalZ = 1f; - - [Fact] - public void NoContactPlane_IsUnsupported() - { - Assert.Equal( - "none", - PhysicsDiagnostics.ClassifySupport( - contactPlaneValid: false, - terrainSampled: true, - contactPlaneZAtXY: 100f, - contactPlaneNormalZ: FlatNormalZ, - terrainZ: 100f, - terrainNormalZ: FlatNormalZ)); - } - - [Fact] - public void PlaneAtTerrainHeightAndTilt_IsTerrain() - { - Assert.Equal( - "terrain", - PhysicsDiagnostics.ClassifySupport( - contactPlaneValid: true, - terrainSampled: true, - contactPlaneZAtXY: 41.25f, - contactPlaneNormalZ: 0.94f, - terrainZ: 41.26f, - terrainNormalZ: 0.94f)); - } - - [Fact] - public void PlaneWellAboveTerrain_IsObject() - { - // The rock-plateau shape: the body rests six metres above the ground. - Assert.Equal( - "object", - PhysicsDiagnostics.ClassifySupport( - contactPlaneValid: true, - terrainSampled: true, - contactPlaneZAtXY: 47.5f, - contactPlaneNormalZ: FlatNormalZ, - terrainZ: 41.5f, - terrainNormalZ: 0.9f)); - } - - [Fact] - public void SameHeightDifferentTilt_IsReportedSeparately() - { - // A collision surface lying flat against sloped ground. This must NOT - // collapse into either answer: it is precisely the ambiguous case, and - // guessing between them is what the probe exists to avoid. - Assert.Equal( - "coplanar-tilt-mismatch", - PhysicsDiagnostics.ClassifySupport( - contactPlaneValid: true, - terrainSampled: true, - contactPlaneZAtXY: 41.5f, - contactPlaneNormalZ: 1.0f, - terrainZ: 41.5f, - terrainNormalZ: 0.72f)); - } - - [Fact] - public void NoTerrainUnderTheBody_SaysSoRatherThanGuessing() - { - Assert.Equal( - "no-terrain", - PhysicsDiagnostics.ClassifySupport( - contactPlaneValid: true, - terrainSampled: false, - contactPlaneZAtXY: 12f, - contactPlaneNormalZ: FlatNormalZ, - terrainZ: float.NaN, - terrainNormalZ: float.NaN)); - } - - [Fact] - public void PlaneHeightIsEvaluatedAtTheBodysOwnXy() - { - // A 45-degree ramp through the origin: height must track X, or a body - // standing on a slope would read as displaced from its own support. - var slope = new Plane(Vector3.Normalize(new Vector3(-1f, 0f, 1f)), 0f); - - Assert.True(PhysicsDiagnostics.TryPlaneZAt(slope, 0f, 0f, out float atOrigin)); - Assert.Equal(0f, atOrigin, 3); - - Assert.True(PhysicsDiagnostics.TryPlaneZAt(slope, 10f, 0f, out float atTen)); - Assert.Equal(10f, atTen, 3); - } - - [Fact] - public void VerticalPlaneHasNoHeight() - { - // A wall is never a floor. Reporting a height for one would read as a - // wildly displaced surface and manufacture a false positive. - var wall = new Plane(new Vector3(1f, 0f, 0f), -5f); - - Assert.False(PhysicsDiagnostics.TryPlaneZAt(wall, 0f, 0f, out _)); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/TransitFailProbeTests.cs b/tests/AcDream.Core.Tests/Physics/TransitFailProbeTests.cs index 99aaf6fa..c2caf4fc 100644 --- a/tests/AcDream.Core.Tests/Physics/TransitFailProbeTests.cs +++ b/tests/AcDream.Core.Tests/Physics/TransitFailProbeTests.cs @@ -137,12 +137,8 @@ public sealed class TransitFailProbeTests string log = sw.ToString(); - // The probe's own families must be completely silent on a healthy - // moving tick. (Console.Out may still carry unrelated one-shot - // process diagnostics — e.g. the #338 AnnounceStepHeightProbeOnce - // self-report, which fires unconditionally on the first IsPlayer - // resolve in the process regardless of any flag — so this checks - // the probe's own tag rather than asserting total silence.) + // The probe's own family must be completely silent on a healthy + // moving tick. Assert.DoesNotContain("[transit-fail", log); float actualDy = result.Position.Y - 0.00f; diff --git a/tests/AcDream.Core.Tests/Physics/WalkMissDiagnosticTests.cs b/tests/AcDream.Core.Tests/Physics/WalkMissDiagnosticTests.cs deleted file mode 100644 index 5581c11a..00000000 --- a/tests/AcDream.Core.Tests/Physics/WalkMissDiagnosticTests.cs +++ /dev/null @@ -1,119 +0,0 @@ -using AcDream.Core.Physics; -using DatReaderWriter.Enums; -using System.Collections.Generic; -using System.Numerics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -/// -/// Tests for the ISSUES #83 H-disambiguation probe spike (spec -/// 2026-05-21-indoor-walk-miss-probe-design.md). -/// -/// Covers: -/// 1. PhysicsDiagnostics.ProbeWalkMissEnabled flag get/set roundtrip. -/// 2. WalkMissDiagnostic.AggregateNearestWalkable selects the nearest -/// walkable polygon by |dz| when the foot XY lies inside a poly's -/// local XY bounding box. -/// 3. WalkMissDiagnostic.AggregateNearestWalkable falls back to the -/// nearest poly by |dz| when no walkable poly XY-contains the foot, -/// reporting ContainsFootXY=false. -/// -public class WalkMissDiagnosticTests -{ - [Fact] - public void ProbeWalkMiss_StaticApi_Roundtrip() - { - bool initial = PhysicsDiagnostics.ProbeWalkMissEnabled; - try - { - PhysicsDiagnostics.ProbeWalkMissEnabled = true; - Assert.True(PhysicsDiagnostics.ProbeWalkMissEnabled); - - PhysicsDiagnostics.ProbeWalkMissEnabled = false; - Assert.False(PhysicsDiagnostics.ProbeWalkMissEnabled); - } - finally - { - PhysicsDiagnostics.ProbeWalkMissEnabled = initial; - } - } - - private static ResolvedPolygon MakeFloorPoly( - Vector3 v00, Vector3 v10, Vector3 v11, Vector3 v01) - { - var verts = new[] { v00, v10, v11, v01 }; - var normal = Vector3.Normalize(Vector3.Cross(v10 - v00, v01 - v00)); - float d = -Vector3.Dot(normal, v00); - return new ResolvedPolygon - { - Vertices = verts, - Plane = new System.Numerics.Plane(normal, d), - NumPoints = 4, - SidesType = CullMode.None, - }; - } - - /// - /// Foot at (0,0,1). Two walkable polys: a low one at Z=0 (foot is - /// 1 m above) and a high one at Z=0.8 (foot is 0.2 m above). - /// Aggregator picks the high one — smaller |dz|. - /// - [Fact] - public void AggregateNearestWalkable_PicksNearestByDz_WhenFootXYInsideMultiplePolys() - { - var lowFloor = MakeFloorPoly( - new Vector3(-5f, -5f, 0f), - new Vector3( 5f, -5f, 0f), - new Vector3( 5f, 5f, 0f), - new Vector3(-5f, 5f, 0f)); - var highFloor = MakeFloorPoly( - new Vector3(-2f, -2f, 0.8f), - new Vector3( 2f, -2f, 0.8f), - new Vector3( 2f, 2f, 0.8f), - new Vector3(-2f, 2f, 0.8f)); - - var resolved = new Dictionary - { - [1] = lowFloor, - [2] = highFloor, - }; - - var result = WalkMissDiagnostic.AggregateNearestWalkable( - resolved, - footLocal: new Vector3(0f, 0f, 1f), - floorZ: PhysicsGlobals.FloorZ); - - Assert.True(result.Found); - Assert.Equal((ushort)2, result.PolyId); - Assert.True(result.ContainsFootXY); - Assert.Equal(0.2f, result.Dz, precision: 5); - Assert.Equal(1.0f, result.NormalZ, precision: 5); - } - - /// - /// Foot at (10,10,1) — outside both poly XY bboxes. Aggregator - /// returns the poly with smallest |dz| but with ContainsFootXY=false. - /// - [Fact] - public void AggregateNearestWalkable_FallsBackByDz_WhenFootXYOutsideAllBboxes() - { - var poly = MakeFloorPoly( - new Vector3(-1f, -1f, 0.5f), - new Vector3( 1f, -1f, 0.5f), - new Vector3( 1f, 1f, 0.5f), - new Vector3(-1f, 1f, 0.5f)); - - var resolved = new Dictionary { [42] = poly }; - - var result = WalkMissDiagnostic.AggregateNearestWalkable( - resolved, - footLocal: new Vector3(10f, 10f, 1f), - floorZ: PhysicsGlobals.FloorZ); - - Assert.True(result.Found); - Assert.Equal((ushort)42, result.PolyId); - Assert.False(result.ContainsFootXY); - Assert.Equal(0.5f, result.Dz, precision: 5); - } -} diff --git a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs index eaf0a5c2..628bc5bf 100644 --- a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs @@ -176,11 +176,4 @@ public sealed class RenderingDiagnosticsTests Assert.Single(set); Assert.Contains(0x020003F2u, set); } - - [Fact] - public void DumpEntitySourceIds_DefaultsEmpty_WhenEnvUnset() - { - // Env var is absent in the test host → probe inert. - Assert.Empty(RenderingDiagnostics.DumpEntitySourceIds); - } } From c1e6e3da44ec422a59d06b1777b79694dcbdaef5 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 12:32:42 +0200 Subject: [PATCH 10/89] =?UTF-8?q?fix=20#435=20(part=202,=20closes=20it):?= =?UTF-8?q?=20attribute=20the=20unowned=20probes=20=E2=80=94=20delete=207,?= =?UTF-8?q?=20reclassify=208,=20restore=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 1 deleted probes whose owning issues were closed. These 14 named no issue at all, so each was traced to its introducing commit (git log -S) instead of guessed at. Attribution split them three ways: DELETED (7, investigations closed): ACDREAM_A8_DUMP_PV and ACDREAM_DUMP_LIVE_SPAWNS (Phase A8), ACDREAM_DUMP_CLOTHING (#37), ACDREAM_DUMP_EDGE_SLIDE (#32), ACDREAM_DUMP_STEPUP (L.2.3d-f), ACDREAM_DUMP_VENDOR (the vendor campaign, 25 call sites across 8 files), ACDREAM_DUMP_VITALS (#5, four independent read sites). VendorDiagnostics.cs went entirely. RECLASSIFIED (8, tools misfiled as probes): the DUMP_CELLS/DUMP_GFXOBJS fixture-extraction family (replay-harness tooling with a roundtrip test), PROBE_CELL (standing cell-transit tracer, pair of the permanent PROBE_RESOLVE), DUMP_SKY and HIDE_PART (generic isolation tools), and DUMP_STEEP_ROOF — which looked like an L.4 relic but observes LIVE divergence-register row AD-56; deleting it would have removed the only runtime lens on an active divergence. All moved to Permanent diagnostics with their attribution recorded. RESTORED (1): ACDREAM_DUMP_MOVE_TRUTH was deleted and un-deleted the same day. It is not a probe — the canonical nine-stop soak (run-connected-r6-soak.ps1) hard-fails every destination without its 'move-truth OUT' records, with a message that would misdirect the next operator. Under the no-workarounds rule the gate's mechanism is restored, not left broken with an IOU (#437, closed). Process lesson recorded on both issues: a closed owning issue is NOT sufficient to delete a probe — grep tools/ and the contract tests for consumers first. Also lands the owner-requested default-off invariant: every diagnostic in the codebase is inert until its env var is explicitly set. Exactly four flags default ON and none is a diagnostic — RETAIL_CHASE, CAMERA_COLLIDE, CAMERA_ALIGN_SLOPE, RETAIL_CLOSE_DEGRADES are retail behaviors wearing an A/B off-switch. That set is now FROZEN by LaunchOptionsDocumentationTests.OnlyTheFourRetailBehaviorFlagsDefaultOn; docs/launch-options.md's Conventions and CLAUDE.md state the rule, and CLAUDE.md now binds future probes to a documented row in the same commit. The client reads 137 environment variables (161 at audit start); 40 temporary probes remain, every one attributed. Full hermetic suite 15,322 passed / 0 failed. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 17 +- docs/ISSUES.md | 127 ++++++++++-- docs/launch-options.md | 67 ++++--- .../Composition/SessionPlayerComposition.cs | 4 +- .../PlayerInteractionMovementSink.cs | 8 - .../SelectionInteractionController.cs | 58 ------ .../Interaction/WorldSelectionQuery.cs | 6 - .../DatLiveEntityProjectionMaterializer.cs | 189 +----------------- .../Rendering/PortalVisibilityBuilder.cs | 51 ----- src/AcDream.App/RuntimeOptions.cs | 8 +- .../UI/ItemInteractionController.cs | 20 -- .../UI/Layout/SelectedObjectController.cs | 19 -- .../World/LiveEntityDeletionController.cs | 14 +- .../World/LiveEntityHydrationController.cs | 35 +--- src/AcDream.Core.Net/GameEventWiring.cs | 32 --- .../Messages/PlayerDescriptionParser.cs | 9 +- src/AcDream.Core.Net/WorldSession.cs | 7 - src/AcDream.Core/Items/VendorDiagnostics.cs | 27 --- .../Physics/PhysicsDiagnostics.cs | 4 +- src/AcDream.Core/Physics/TransitionTypes.cs | 145 +------------- .../RuntimeInteractionTransactionState.cs | 15 -- .../Gameplay/VendorShopItemMaterializer.cs | 11 - .../LaunchOptionsDocumentationTests.cs | 65 +++++- .../AcDream.App.Tests/RuntimeOptionsTests.cs | 7 +- .../Physics/CellarLipWedgeTests.cs | 2 - .../Physics/DoorCollisionApparatusTests.cs | 2 - 26 files changed, 256 insertions(+), 693 deletions(-) delete mode 100644 src/AcDream.Core/Items/VendorDiagnostics.cs diff --git a/CLAUDE.md b/CLAUDE.md index 808da717..90acfba6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1572,12 +1572,21 @@ Every environment variable and command-line argument the client reads — what it does, its exact value shape, and **what else it changes about the run** — is documented in [`docs/launch-options.md`](docs/launch-options.md). That file is the single -source of truth and is enforced by `LaunchOptionsDocumentationTests`: a -flag without a documented row fails the build, and so does a documented row -whose read site was deleted. +source of truth for every probe we have and how to turn one on, and it is +enforced by `LaunchOptionsDocumentationTests`: a flag without a documented +row fails the build, and so does a documented row whose read site was +deleted. **Any future probe that stays in the code gets its row there in +the same commit — no exceptions.** -Two habits that list exists to enforce: +The binding rules: +- **Every probe and dump is OFF by default.** Nothing that prints, records, + or costs performance may activate without its env var explicitly set + (`=1`). The only default-on flags are retail *behaviors* wearing an + A/B off-switch (`ACDREAM_RETAIL_CHASE`, `ACDREAM_CAMERA_COLLIDE`, + `ACDREAM_CAMERA_ALIGN_SLOPE`, `ACDREAM_RETAIL_CLOSE_DEGRADES` — `=0` + disables); that set is frozen by `LaunchOptionsDocumentationTests` — + never add a default-on diagnostic. - **Read the side-effects column before any measurement.** Flags that look inert are not: `ACDREAM_AUTOMATION_ARTIFACT_DIR` also builds a per-frame diagnostics referee (#432), and `ACDREAM_STREAM_RADIUS` measures a diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 44f49b0b..915cada9 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,16 +24,16 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. -## #435 — PARTLY CLOSED: Probe debt: 17 temporary probes outlived their closed investigations, 14 more name no owner +## #435 — PARTLY CLOSED: Probe debt: 25 temporary probes outlived their closed investigations, 6 more name no owner -**Status:** The 17 orphaned probes are DELETED (2026-08-24) — 3,493 lines -removed, flag count 161 → 144, temporary probes 64 → 47. Build clean; full -hermetic suite 15,321 passed / 0 failed (baseline 15,333 minus the 12 tests -whose only subject was a deleted probe). Four files went entirely: -`WalkMissDiagnostic.cs`, `CollisionMeshWireframe.cs` and two probe-only test -files. `LaunchOptionsDocumentationTests` did its job during the cleanup — -it refused the deletion until the doc's rows moved to Retired and the frozen -direct-read counts were lowered (`PhysicsEngine.cs` to zero, +**Status:** The 17 orphaned probes from part 1 are DELETED (2026-08-24) — +3,493 lines removed, flag count 161 → 144, temporary probes 64 → 47. Build +clean; full hermetic suite 15,321 passed / 0 failed (baseline 15,333 minus +the 12 tests whose only subject was a deleted probe). Four files went +entirely: `WalkMissDiagnostic.cs`, `CollisionMeshWireframe.cs` and two +probe-only test files. `LaunchOptionsDocumentationTests` did its job during +the cleanup — it refused the deletion until the doc's rows moved to Retired +and the frozen direct-read counts were lowered (`PhysicsEngine.cs` to zero, `TransitionTypes.cs` 3 → 2). Notable: `TransitionTypes.SetContactPlane` shed its `CallerMemberName` / @@ -43,9 +43,45 @@ call site passed them, so no behavior changed. F2's collision overlay survives and reverts to the proxy-cylinder form, as intended when `ACDREAM_WIRE_MESH` went. -**STILL OPEN — the 14 unattributed probes.** They name no owning issue, so -nothing records when they are safe to remove. Deleting them on a guess is -how a future investigation loses apparatus it needed. The right next step is +**Part 2 (2026-08-24):** traced each of the 14 then-unattributed rows to +its introducing commit and confirmed 8 belonged to closed investigations — +`ACDREAM_A8_DUMP_PV` (Phase A8.F, closed), `ACDREAM_DUMP_CLOTHING` (#37, +DONE 2026-05-11), `ACDREAM_DUMP_EDGE_SLIDE` (#32, CLOSED 2026-08-07), +`ACDREAM_DUMP_LIVE_SPAWNS` (Phase A8, closed), `ACDREAM_DUMP_MOVE_TRUTH` +(#30/#34, DONE 2026-04-29), `ACDREAM_DUMP_STEPUP` (L.2.3d/e/f, closed), +`ACDREAM_DUMP_VENDOR` (vendor campaign, closed 2026-08-08), +`ACDREAM_DUMP_VITALS` (#5, DONE 2026-04-25). All 8 deleted along with their +call sites (`~130` net lines across 21 files), plus `VendorDiagnostics.cs` +(the `ACDREAM_DUMP_VENDOR` owner class, now fully unreferenced) and +`MovementTruthDiagnosticController`'s internals (kept as a permanent +no-op `IMovementTruthDiagnosticSink` implementation — `PlayerModeController`, +`GameWindow`, `LiveEntityNetworkUpdateController`, and +`SessionPlayerComposition` all still construct/wire it, so the DI graph is +unchanged). `docs/launch-options.md` rows moved to Retired; +`LaunchOptionsDocumentationTests`' `DirectReadDebt` lowered for +`PortalVisibilityBuilder.cs` (1→0, dropped), `GameEventWiring.cs` (1→0, +dropped), `PlayerDescriptionParser.cs` (1→0, dropped), +`TransitionTypes.cs` (2→0, dropped), `WorldSession.cs` (3→2). Build clean, +0 warnings; full filtered suite 15,315 passed / 0 failed / 0 skipped (no +`[Fact]`/`[Theory]` was removed — the small delta from part 1's 15,321 +baseline is pre-existing run-to-run count variance, not a test loss). + +**Found during part 2, not fixed (separate from #435's scope):** see #437 +— `tools/run-connected-r6-soak.ps1` sets `ACDREAM_DUMP_MOVE_TRUTH=1` and +greps its own log for `move-truth OUT` lines as part of its automated +movement-verification gate. That signal is now permanently dead (the flag +is a no-op), but the ps1's own text is unchanged so it and its pinning +contract test (`ConnectedWorldSoakRouteContractTests. +LaunchConfigurationIsDisclosedToTheArtifactDirectoryBeforeLaunch`) both +still pass — they assert the ps1's *text*, not that the mechanism it +describes still works. + +**STILL OPEN — the 6 remaining unattributed probes** (`ACDREAM_DUMP_CELLS_DIR`, +`ACDREAM_DUMP_GFXOBJS_DIR`, `ACDREAM_DUMP_SKY`, `ACDREAM_DUMP_STEEP_ROOF`, +`ACDREAM_HIDE_PART`, `ACDREAM_PROBE_CELL` — all deliberately left alone +this pass per the #435 part-2 scope). They name no owning issue, so nothing +records when they are safe to remove. Deleting them on a guess is how a +future investigation loses apparatus it needed. The right next step is attribution, not deletion: for each, find the commit that introduced it (`git log -S ACDREAM_PROBE_X`), record the issue in its `docs/launch-options.md` row, and only then decide. Deliberately deferred. @@ -131,6 +167,73 @@ differ from these two placeholder strings. --- +## #437 — CLOSED: The R6 soak's movement-verification signal died with a deleted probe — resolved by RESTORING the probe + +**Status:** CLOSED 2026-08-24, same day, by restoration — not by choosing a +replacement signal. On review, the deletion premise was wrong: +`ACDREAM_DUMP_MOVE_TRUTH` is not a spent investigation probe but +**automation apparatus** — `run-connected-r6-soak.ps1` (the canonical +nine-stop soak) HARD-FAILS every destination when `$moveTruthDelta < 2`, +with a message that would misdirect the next operator ("production +movement did not deliver outbound records" when in truth the diagnostic +was deleted). Under the no-workarounds rule the honest fix is to restore +the mechanism the gate depends on, not to leave the gate broken with an +IOU. Restored in full: `MovementTruthDiagnosticController`, +`RuntimeOptions.DumpMoveTruth` (now carrying a comment naming the soak +dependency), the GameWindow wiring, the RuntimeOptions tests, and the +gate-script allow-list entries. Its `docs/launch-options.md` row moved to +the Automation section with the dependency spelled out. Process lesson, +recorded on #435: "its issue is closed" is NOT sufficient to delete a +probe — grep `tools/` and the contract tests for consumers first (the +original #435 part 1 did this; part 2's dispatch omitted it). + +**Original report follows.** + +**Status (original):** OPEN +**Severity:** LOW (measurement-tooling gap, not a client defect — the gate +still runs and its OTHER checks still verify real behavior) +**Filed:** 2026-08-24 (found while closing #435 part 2) +**Component:** connected-gate tooling (`tools/run-connected-r6-soak.ps1`) + +**Symptom:** `run-connected-r6-soak.ps1` sets `ACDREAM_DUMP_MOVE_TRUTH=1` +before launch, then during each destination's checkpoint asserts the log +contains at least two fresh `move-truth OUT` lines +(`Wait-ForLogPattern $Client $stdoutLog 'move-truth OUT' ...`) and reports +a `$moveTruthDelta` count — this was one of several signals the script uses +to prove the client actually issued outbound movement during the run. +`ACDREAM_DUMP_MOVE_TRUTH` was retired in #435 part 2: +`MovementTruthDiagnosticController` is now a permanent no-op, so +`move-truth OUT` can never appear in the log again. The script still runs +(its forward/jump/combat input-dispatch checks are independent and still +real), but the movement-specific corroboration silently stops meaning +anything — a soak that broke outbound movement entirely would no longer be +caught by this particular check. + +**Why it wasn't fixed inline:** the fix requires choosing a NEW signal to +verify outbound movement happened (e.g. a different existing log line, a +wire-level assertion, or a purpose-built lightweight counter) — a design +decision outside the scope of a probe-deletion pass, not a mechanical +rename. `docs/ISSUES.md`/CLAUDE.md's "no workarounds without approval" rule +applies: inventing a replacement signal without checking it against what +the script's other checks already prove would risk a false sense of +coverage. + +**Corroborating detail:** `ConnectedWorldSoakRouteContractTests. +LaunchConfigurationIsDisclosedToTheArtifactDirectoryBeforeLaunch` +(`tests/AcDream.App.Tests/Diagnostics/ConnectedWorldSoakRouteContractTests.cs`) +asserts `$env:ACDREAM_DUMP_MOVE_TRUTH = '1'` appears in the ps1's source — +a text-content assertion, not a runtime one, so it still passes and gives +no signal that the mechanism died. + +**Fix shape:** decide on a replacement runtime signal for "the client +issued outbound movement" (candidates: an existing non-diagnostic log line +already emitted by the movement pipeline, or a small permanent counter +exposed through an existing owner class), wire it into the soak script's +per-destination checkpoint in place of the `move-truth OUT` grep, and +update the disclosure-contract test to match. + +--- + ## #434 — CLOSED: The DebugPanel/DebugVM developer surface is unreachable, and ~40 doc comments still advertise it as live **Status:** CLOSED 2026-08-24. Deleted `DebugPanel.cs` (340 lines), diff --git a/docs/launch-options.md b/docs/launch-options.md index d732c5fe..37487fa0 100644 --- a/docs/launch-options.md +++ b/docs/launch-options.md @@ -37,6 +37,15 @@ Assume a flag has a side effect until its row says otherwise. ## Conventions +- **Everything diagnostic is OFF by default.** Every probe, dump, capture, + and measurement flag in this document is inert until its variable is + explicitly set — an unset environment runs zero diagnostics. Exactly + four flags default ON, and none is a diagnostic: `ACDREAM_RETAIL_CHASE`, + `ACDREAM_CAMERA_COLLIDE`, `ACDREAM_CAMERA_ALIGN_SLOPE`, and + `ACDREAM_RETAIL_CLOSE_DEGRADES` are retail *behaviors* wearing an A/B + off-switch (`=0` disables the behavior for a comparison run). That + four-flag set is frozen by `LaunchOptionsDocumentationTests` — a new + default-on flag fails the build. - `=1` means the code tests for exactly the string `1`. Setting `true`, `yes`, or `0` does **not** enable such a flag (and `0` does not disable one whose test is "is the variable present"). @@ -225,8 +234,9 @@ $env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv" | `ACDREAM_UI_PROBE_DUMP` | `=1` | Enables the retail-UI automation probe's diagnostic dump path and feeds `RetailUiProbeBindings`/`RetailUiAutomationScriptRunner`. Also part of `RuntimeOptions.UiProbeEnabled` (`UiProbeDump \ | \ | UiProbeScript is set`). | `RuntimeOptions.UiProbeDump` → `LivePresentationComposition.cs:1465-1495`, `InteractionRetainedUiComposition.cs:1092-1100` | | `ACDREAM_UI_PROBE_SCRIPT` | `=` | Path to a script file the `RetailUiAutomationScriptRunner` executes against the retained UI (pointer/semantic-input command playback) for scripted UI regression testing. | Also flips `RuntimeOptions.UiProbeEnabled` true even without `ACDREAM_UI_PROBE_DUMP=1`. | `null` | `RuntimeOptions.UiProbeScript` → `InteractionRetainedUiComposition.cs:1094` | | `ACDREAM_VULKAN_FORCE_UNSUPPORTED` | `=` (case-insensitive property name, e.g. `MultiDrawIndirect`) | Test knob (Slice V5): clears one named required Vulkan feature from the capability record to synthetically fail the gate, so the `NotSupportedException` → exit-code-4 → report path can be exercised on hardware that actually supports everything. | Deliberately breaks Vulkan startup when set to a matched feature name — this is a "make it fail on purpose" gate-testing flag, never appropriate for a normal or measurement run. | `null` → real capabilities used unmodified | `RuntimeOptions.VulkanForcedUnsupportedFeature` → `VulkanCapabilityRecord.Without` (`VulkanCapabilityRecord.cs:113-119`), consumed at `VulkanGraphicsContext.cs:339` | -| `ACDREAM_VULKAN_PROBE` | `=1` | Runs the standalone Vulkan capability-probe/bring-up harness (opens its own window, runs the capability gate, presents synthetic V6c/V6d verification scenes, captures one screenshot) **instead of** the real client composition host, then exits. | Its class doc (`VulkanBringUpHost.cs:11`) is stale — claims it additionally requires `ACDREAM_RENDER_BACKEND=vulkan`, which is no longer read anywhere (see that flag's row); in the current code this flag ALONE gates entry (`GameWindow.cs:828: if (_options.VulkanCapabilityProbe)`). | `false` → normal composition host | `RuntimeOptions.VulkanCapabilityProbe` → `GameWindow.cs:828` → `VulkanBringUpHost` | +| `ACDREAM_VULKAN_PROBE` | `=1` | Runs the standalone Vulkan capability-probe/bring-up harness (opens its own window, runs the capability gate, presents synthetic V6c/V6d verification scenes, captures one screenshot) **instead of** the real client composition host, then exits. | This flag ALONE gates entry (`GameWindow.cs:828`); the former `ACDREAM_RENDER_BACKEND=vulkan` co-requisite died with the OpenGL backend (its class doc was corrected 2026-08-24). | `false` → normal composition host | `RuntimeOptions.VulkanCapabilityProbe` → `GameWindow.cs:828` → `VulkanBringUpHost` | | `ACDREAM_VULKAN_PROBE_FRAMES` | `=` (non-negative) | Bounds the bring-up probe harness to N presented frames so it can run unattended in CI, instead of presenting until a human closes the window. | The frame budget never cuts a pending screenshot capture short — the loop stays open until the screenshot has been attempted even past the budget, so an unattended run's whole product (a PNG) is guaranteed. Zero (unset/unparseable/explicit `0`) keeps the interactive wait-for-close behavior. | `0` → interactive (wait for window close) | `RuntimeOptions.VulkanCapabilityProbeFrames` → `VulkanBringUpHost.cs:141-249` | +| `ACDREAM_DUMP_MOVE_TRUTH` | `=1` | Emits one `move-truth OUT` line per outbound movement record (MoveToState / AutonomousPosition): local resolved position vs the wire position/cell, ground contact, velocity (`MovementTruthDiagnosticController`). | **Automation apparatus, NOT a spent probe** — the canonical nine-stop soak (`tools/run-connected-r6-soak.ps1`) hard-gates on ≥2 of these lines per destination as its proof that production input produced outbound movement traffic; deleting it fails the soak at every stop (#437, deleted-and-restored 2026-08-24). Print volume follows the outbound send cadence. | off | `RuntimeOptions.DumpMoveTruth` → `GameWindow.cs` → `MovementTruthDiagnosticController` | ## Permanent diagnostics @@ -253,6 +263,14 @@ $env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv" | `ACDREAM_PROBE_USEABILITY_FALLBACK` | `=1` | gates a per-call log of `IsUseableTarget` calls that take the null-useability fallback path (creature/door/lifestone passes) (measures a real ace-vs-retail data gap, not a bug investigation) | print-only; measures how often ACE ships entities without `_useability` set | off | `PhysicsDiagnostics.ProbeUseabilityFallbackEnabled` | | `ACDREAM_PROBE_VIS` | `=1` | emits `[vis]` line on root-cell CHANGE: visible cell ids, OutsideView poly/plane counts, per-cell plane counts, scissor-fallback count (phase u.2d repurposed the flag; its DebugPanel mirror is unreachable — #434) | print-only; ALSO implicitly enables the separate `ACDREAM_PROBE_ENVCELL` probe (its getter ORs with this flag — see Notes #3); startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.ProbeVisibilityEnabled` | | `ACDREAM_REMOTE_VEL_DIAG` | `=1` | prints per-UM/per-tick remote-velocity and animation-cycle diagnostic lines; `Runtime/Physics/RemoteMotion.cs` carries diagnostic-only fields (`PrevServerPos`, `PrevServerPosTime`, `MaxRootMotionSpeedSinceLastUP`, `LastOmegaDiagLogTime`) unconditionally on every remote — small fixed per-instance memory regardless of the flag, not gated (long-lived remote-velocity/animation diagnostic, commit a.1) | print-only, but the raw-site reads in `LiveEntityNetworkUpdateController.cs` fire on every UM/tick even when off (rule-5 violation, `Environment.GetEnvironmentVariable` call per event, 6+ call sites) | off | THREE readers: `AnimationPresentationDiagnostics.FromEnvironment()` (App owner record, cached at startup, consumed by `LiveEntityAnimationPresenter` for `[SEQSTATE]`/`[CURRNODE]`/other part-diagnostic lines, throttled to 1/sec/entity) + raw `Environment.GetEnvironmentVariable` reads scattered across `LiveEntityNetworkUpdateController.cs` (6+ sites: `[UM_RAW]`, `[FWD_WIRE]`, `[VEL_DIAG]`, `[UPCYCLE_SRC]`, `[UM_STALE]`) + `RemoteServerControlledVelocityCycle.cs:68` (`[UPCYCLE]`) | +| `ACDREAM_DUMP_CELLS` | `=` | one-shot JSON dump of any cached EnvCell whose id matches the list, to `ProbeDumpCellsPath` (issue #98 fixture capture) — Standing fixture-extraction tooling (A6.P3/#98 lineage) for the physics replay harness; roundtrip-tested. Not investigation-scoped. | file I/O once per matching cell id (no-op on repeat); fixture-generation tool, not a perf-neutral no-op when ids are listed | off/unset | `PhysicsDiagnostics.ProbeDumpCellIds` (`ParseHexIdList`) | +| `ACDREAM_DUMP_CELLS_DIR` | `=` | overrides the output directory for `ACDREAM_DUMP_CELLS` — Companion output-directory knob for ACDREAM_DUMP_CELLS. | print/file-path only; no effect unless `ACDREAM_DUMP_CELLS` is also set | off/unset | `PhysicsDiagnostics.ProbeDumpCellsPath` | +| `ACDREAM_DUMP_GFXOBJS` | `=` | one-shot JSON dump of any cached GfxObj's polygon table + BSP root metadata matching the list, to `ProbeDumpGfxObjsPath` (issue #98 fixture capture) — Standing fixture-extraction tooling (A6.P3/#98 lineage), pair of DUMP_CELLS. | file I/O once per matching id (no-op on repeat) | off/unset | `PhysicsDiagnostics.ProbeDumpGfxObjIds` (`ParseHexIdList`) | +| `ACDREAM_DUMP_GFXOBJS_DIR` | `=` | overrides the output directory for `ACDREAM_DUMP_GFXOBJS` — Companion output-directory knob for ACDREAM_DUMP_GFXOBJS. | print/file-path only; no effect unless `ACDREAM_DUMP_GFXOBJS` is also set | off/unset | `PhysicsDiagnostics.ProbeDumpGfxObjsPath` | +| `ACDREAM_DUMP_SKY` | `=1` | Print-only: dumps decoded `SkyDesc` raw values on region load (`SkyDescLoader.cs`) and per-GfxObj `Surface.Type`/translucency flags on first upload (`SkyRenderer.cs`), plus gates a `TimeSync` console diagnostic in `GameWindow`. Built to resolve specific open questions about retail sky units and GfxObjReplace timing (2026-04-23 research), now answered but the dumps remain wired. — Generic sky-keyframe isolation dump (introduced with the phase-1 tint revert); a tool, not a bug probe. | Three independent reads of the SAME env var, only one of which (`RuntimeOptions.DumpSky`) goes through the typed options object; the other two are raw scattered reads (see Notes). `SkyRenderer.cs:582`'s raw read is in the App layer and has no architectural excuse for bypassing `RuntimeOptions` — `_options.DumpSky` was already available to that composition. `print-only` in all three sites. | off/unset | `RuntimeOptions.DumpSky` (typed) → `GameWindow.cs:704` (`TimeSyncDiagnostic`); **also** two independent raw `Environment.GetEnvironmentVariable` reads at `SkyDescLoader.cs:392` (Core) and `SkyRenderer.cs:582` (App) | +| `ACDREAM_DUMP_STEEP_ROOF` | `=1` | gates `[steep-roof] KILL-VELOCITY-APPLIED` in `PhysicsEngine.ResolveWithTransition` when retail's `kill_velocity` zeroes body velocity on steep-slope impact, plus per-frame plane-normal traces in `TransitionTypes`/`PlayerMovementController` — KEEP: observes LIVE divergence-register row AD-56 (the plumb-fall freeze on steep-but-walkable polys, restored 2026-08-07). The only runtime lens on that active divergence; delete only with the AD-56 row itself. | print-only | off/unset | `PhysicsDiagnostics.DumpSteepRoofEnabled` | +| `ACDREAM_HIDE_PART` | `=` | Hides one mesh part by index on entities with ≥10 parts (humanoids) — a debugging aid for equipment/clothing part-visibility issues. — Generic model-part isolation tool (issue #37 lineage but general-purpose since); a tool, not a bug probe. | Real (visible) behavior change, not print-only, but scoped to a single diagnostic index and off by default. | off/unset | `RuntimeOptions.HidePartIndex` → `LivePresentationComposition.cs:608` → `LiveEntityAnimationPresenter.cs:21,38,243` | +| `ACDREAM_PROBE_CELL` | `=1` | gates one `[cell-transit]` line per `PlayerMovementController.CellId` change (old→new cell, position, reason tag) — Standing cell-transit tracer (L.2a slice 1), pair of the permanent ACDREAM_PROBE_RESOLVE; recurs in every membership investigation. | print-only; low volume (only on actual cell crossings) | off/unset | `PhysicsDiagnostics.ProbeCellEnabled` | ## Temporary probes @@ -261,39 +279,33 @@ the same commit as its investigation's fix** — if you find one here whose issue is closed, the strip was missed; delete both. > **Probe debt, measured 2026-08-24:** 64 temporary probes existed, citing 21 -> distinct issues with 14 already closed. [#435](ISSUES.md) stripped the 17 -> rows whose investigation had ended without the strip — see the Retired -> section below for their removal record — leaving 47. A further 14 rows -> (unchanged by this pass) name no owning issue at all, which is worse: -> nobody can tell when they are safe to remove. Every probe here still costs -> a branch on its hot path even when unset, and a handful re-read the -> environment per frame rather than caching (see their side-effects column). +> distinct issues with 14 already closed. [#435](ISSUES.md) part 1 stripped +> the 17 rows whose investigation had ended without the strip — see the +> Retired section below for their removal record — leaving 47. Part 2 +> traced each of the (then-)14 unattributed rows to its introducing commit +> and stripped the 7 that belonged to closed investigations +> (`ACDREAM_A8_DUMP_PV`/Phase A8, `ACDREAM_DUMP_CLOTHING`/#37, +> `ACDREAM_DUMP_EDGE_SLIDE`/#32, `ACDREAM_DUMP_LIVE_SPAWNS`/Phase A8, +> `ACDREAM_DUMP_STEPUP`/L.2.3d-f, `ACDREAM_DUMP_VENDOR`/the vendor +> campaign, `ACDREAM_DUMP_VITALS`/#5), leaving 40. An eighth, +> `ACDREAM_DUMP_MOVE_TRUTH`, was deleted and then RESTORED the same day: +> it turned out to be automation apparatus, not a probe — the canonical +> nine-stop soak hard-gates on its output (see its row under Automation; +> #437 is the record). The remaining rows attributed at part 2 were +> reclassified into Permanent diagnostics as standing tools rather than +> investigation probes. Every probe here still costs a branch on its hot +> path even when unset, and a handful re-read the environment per frame +> rather than caching (see their side-effects column). | Flag | Owning investigation | Value | What it does | Side effects | Read by | |---|---|---|---|---|---| -| `ACDREAM_A8_DUMP_PV` | (unattributed) | `=1` | Dumps local→NDC→clipped portal geometry (first 2 `Build` calls per distinct camera cell) | print-only (`Console.WriteLine`) | `PortalVisibilityBuilder.cs:270-271` (static field, not a diagnostics-owner class) | | `ACDREAM_CLIP_DEBUG` | #176 | `=1` | forces the EnvCell SHELL pass to map every instance to clip slot 0 (no-clip) instead of its cell's portal-slice region | ALTERS RENDERED OUTPUT: shells draw whole/unclipped instead of trimmed — a visual isolation mode, not a log-only probe; no DebugPanel mirror | `RenderingDiagnostics.ClipDebugNoShellTrim` | | `ACDREAM_DUMP_APPEARANCE` | #5 | `="1"` | Logs every `0xF625` ObjDescEvent + `0xF7DB` UpdateObject with body length, target guid, hex preview — used to debug remote-player appearance asymmetry | print-only (`Console.WriteLine`) | `WorldSession` static field `DumpAppearanceEnabled` (`WorldSession.cs:792-793`), raw scattered read, issue #5 diagnostic | -| `ACDREAM_DUMP_CELLS` | #98 | `=` | one-shot JSON dump of any cached EnvCell whose id matches the list, to `ProbeDumpCellsPath` (issue #98 fixture capture) | file I/O once per matching cell id (no-op on repeat); fixture-generation tool, not a perf-neutral no-op when ids are listed | `PhysicsDiagnostics.ProbeDumpCellIds` (`ParseHexIdList`) | -| `ACDREAM_DUMP_CELLS_DIR` | (unattributed) | `=` | overrides the output directory for `ACDREAM_DUMP_CELLS` | print/file-path only; no effect unless `ACDREAM_DUMP_CELLS` is also set | `PhysicsDiagnostics.ProbeDumpCellsPath` | -| `ACDREAM_DUMP_CLOTHING` | (unattributed) | `=1` | Print-only: dumps clothing/part-swap diagnostics for a spawned entity when its setup has ≥10 mesh parts (humanoids). Gated additionally on part count even when the flag is on. | `print-only` | `RuntimeOptions.DumpClothing` → `DatLiveEntityProjectionMaterializer.cs:251-258,1190` | -| `ACDREAM_DUMP_EDGE_SLIDE` | (unattributed) | `=1` | gates five `edge-slide:` trace lines (stepdown-failed, stepdown-branch-enter, phase2, branch, cliffslide) inside the L.4-diag edge-slide/cliff-slide code path | print-only; property re-reads `Environment.GetEnvironmentVariable` on EVERY call (not cached in a field) — repeated env lookups during edge-slide resolution when active; raw read outside any diagnostics-owner class (rule-5 candidate) | `Transition.DumpEdgeSlideEnabled` (private expression-bodied property in `TransitionTypes.cs`, raw read) | -| `ACDREAM_DUMP_GFXOBJS` | #98 | `=` | one-shot JSON dump of any cached GfxObj's polygon table + BSP root metadata matching the list, to `ProbeDumpGfxObjsPath` (issue #98 fixture capture) | file I/O once per matching id (no-op on repeat) | `PhysicsDiagnostics.ProbeDumpGfxObjIds` (`ParseHexIdList`) | -| `ACDREAM_DUMP_GFXOBJS_DIR` | (unattributed) | `=` | overrides the output directory for `ACDREAM_DUMP_GFXOBJS` | print/file-path only; no effect unless `ACDREAM_DUMP_GFXOBJS` is also set | `PhysicsDiagnostics.ProbeDumpGfxObjsPath` | -| `ACDREAM_DUMP_LIVE_SPAWNS` | (unattributed) | `=1` | Print-only: logs every live `CreateObject` spawn as it's processed, plus DROP lines when a setup dat id is missing. | `print-only` | `RuntimeOptions.DumpLiveSpawns` → `DatLiveEntityProjectionMaterializer.cs:161-226`, `SessionPlayerComposition.cs:566,712` | -| `ACDREAM_DUMP_MOVE_TRUTH` | (unattributed) | `=1` | Print-only: records the local player's last outbound movement wire truth (position, cell, contact byte, velocity) for comparing what was actually sent vs. local physics state. Early-returns with zero cost when disabled. | `print-only` | `RuntimeOptions.DumpMoveTruth` → `GameWindow.cs:795` → `MovementTruthDiagnosticController` | | `ACDREAM_DUMP_OPCODES` | #5 | `="1"` | Logs first occurrence of each genuinely-unhandled inbound opcode (deduped by opcode) | print-only. Must stay the LAST else-if in the dispatch chain per comment (else it would intercept handled opcodes) — currently correct. | `WorldSession` static field `DumpOpcodesEnabled` (`WorldSession.cs:788-789`, consumed `WorldSession.cs:2391-2398`), issue #5 diagnostic. Also mirrored (display-only, non-functional) via `DebugPanel.cs:241`/`DebugVM.cs:227`. | | `ACDREAM_DUMP_SCENERY_Z` | #48 | `=1` | Per-spawn Z-placement diagnostic for procedural scenery (trees/bushes/rocks), added for issue #48 (the "trees-in-sky" bug). | **NOT print-only** — this is a real behavior fork, not just added logging. `LandblockBuildFactory.cs:167-178`: when the flag is on, the streaming worker calls a **separate, duplicate scenery-building method** (`BuildSceneryEntitiesForStreaming`, a full parallel reimplementation of GfxObj/Setup mesh resolution + placement inline in this file) instead of production's `LandblockPhysicsContentBuilder.HydrateProceduralScenery`. Any visual/measurement run taken with this flag set is exercising a different scenery-placement code path than production, which can drift from it silently. | `RuntimeOptions.DumpSceneryZ` → `SessionPlayerComposition.cs:280` → `LandblockBuildFactory.cs:23,42,168,335` | -| `ACDREAM_DUMP_SKY` | (unattributed) | `=1` | Print-only: dumps decoded `SkyDesc` raw values on region load (`SkyDescLoader.cs`) and per-GfxObj `Surface.Type`/translucency flags on first upload (`SkyRenderer.cs`), plus gates a `TimeSync` console diagnostic in `GameWindow`. Built to resolve specific open questions about retail sky units and GfxObjReplace timing (2026-04-23 research), now answered but the dumps remain wired. | Three independent reads of the SAME env var, only one of which (`RuntimeOptions.DumpSky`) goes through the typed options object; the other two are raw scattered reads (see Notes). `SkyRenderer.cs:582`'s raw read is in the App layer and has no architectural excuse for bypassing `RuntimeOptions` — `_options.DumpSky` was already available to that composition. `print-only` in all three sites. | `RuntimeOptions.DumpSky` (typed) → `GameWindow.cs:704` (`TimeSyncDiagnostic`); **also** two independent raw `Environment.GetEnvironmentVariable` reads at `SkyDescLoader.cs:392` (Core) and `SkyRenderer.cs:582` (App) | -| `ACDREAM_DUMP_STEEP_ROOF` | (unattributed) | `=1` | gates `[steep-roof] KILL-VELOCITY-APPLIED` in `PhysicsEngine.ResolveWithTransition` when retail's `kill_velocity` zeroes body velocity on steep-slope impact, plus per-frame plane-normal traces in `TransitionTypes`/`PlayerMovementController` | print-only | `PhysicsDiagnostics.DumpSteepRoofEnabled` | -| `ACDREAM_DUMP_STEPUP` | (unattributed) | `=1` | prints `stepup: enter normal=… verdict=WALKABLE/STEEP …` on every step-up attempt | print-only; raw per-call `Environment.GetEnvironmentVariable` read outside a diagnostics-owner class (rule-5 violation); content is mirrored (not replaced) into the buffered `[transit-fail-stepup]` trace gated separately by `ACDREAM_DUMP_TRANSIT_FAIL` | raw read in `Transition.DoStepUp` (`TransitionTypes.cs:5991`, re-read every call — not cached) | | `ACDREAM_DUMP_TRANSIT_FAIL` | #345 | `=1` | buffers per-tick `[transit-fail-insert]`/`[transit-fail-stepup]`/`[transit-fail-walk]`/`[transit-fail-adjust]` trace lines into a `[ThreadStatic]` list and flushes them to console ONLY when a tick requested nonzero XY movement but delivered zero (self-selecting "stuck tick" predicate) | print-only, zero allocation when off (flag checked before touching any buffer per its own doc); buffer/list allocation only on ticks that are already stuck | `PhysicsDiagnostics.DumpTransitFailEnabled` | -| `ACDREAM_DUMP_VENDOR` | (unattributed) | `="1"` | `[vendor-diag]`-prefixed trace across ~25 call sites for two live-only vendor regressions: Chain A (far-click walk-to-use approach never opens the shop window) and Chain B (splittable vendor stack selection shows no quantity slider) | print-only (`Console.WriteLine`), verified true no-op when unset | `VendorDiagnostics.DumpVendorEnabled` (`VendorDiagnostics.cs:25-26`) — a proper diagnostics-owner class per Code Structure Rule 5, shared across App/Core.Net/Runtime | -| `ACDREAM_DUMP_VITALS` | (unattributed) | `="1"` | Logs every `PrivateUpdateVital(Current)` parse, every parsed `PlayerDescription` (vector flags/attr/spell counts), and `PlayerDescriptionParser` trailer/mid-walk `FormatException` failures with position | print-only at every site. `PlayerDescriptionParser.cs:458/473` re-read the env var raw inside `catch` blocks on every parse failure (rare, but scattered/uncached). | Read independently (not shared) at 4 sites: `WorldSession.cs:790-791` (`DumpVitalsEnabled`), `GameEventWiring.cs:1041` (local `dumpPd` at PlayerDescription registration), `PlayerDescriptionParser.cs:458` and `:473` (per-catch-block raw reads). Also mirrored (display-only, non-functional) via `DebugPanel.cs:240`/`DebugVM.cs:225`. | -| `ACDREAM_HIDE_PART` | (unattributed) | `=` | Hides one mesh part by index on entities with ≥10 parts (humanoids) — a debugging aid for equipment/clothing part-visibility issues. | Real (visible) behavior change, not print-only, but scoped to a single diagnostic index and off by default. | `RuntimeOptions.HidePartIndex` → `LivePresentationComposition.cs:608` → `LiveEntityAnimationPresenter.cs:21,38,243` | | `ACDREAM_LIGHT_DEBUG` | #176 | `=` (`int.TryParse`; unset/invalid → 0) | shader isolation mode uploaded as `uLightDebug` by `EnvCellRenderer` + `WbDrawDispatcher`: 0=off, 1=ambient-only vertex lighting, 2=kill dynamic point lights, 3=raw vLit visualization (texture ignored) | ALTERS RENDERED OUTPUT directly every draw pass (changes fragment-shader lighting/texturing) — not a log probe; no DebugPanel mirror | `RenderingDiagnostics.LightDebugMode` | | `ACDREAM_PROBE_BUILDING` | l.2d slice 1 | `=1` | gates the multi-line `[resolve-bldg]` BSP-shadow-hit trace in `TransitionTypes.FindObjCollisions`, one-time `[entity-source]` registration logs in `GameWindow`, `[door-cycle]` UM dispatch trail, and a one-shot `[setstate-hex]` wire dump of the first `SetState` (0xF74B) packet in `WorldSession` | print-only; also un-gates the `PhysicsDiagnostics.LastBspHitPoly` diagnostic side-channel (a static field write in `BSPQuery`/`FlatBspQuery`, read back by the `[resolve-bldg]` line) — no gameplay effect, but an extra static-field write per BSP hit while on; heavy output (one multi-line entry per BSP hit per physics tick) | `PhysicsDiagnostics.ProbeBuildingEnabled` | -| `ACDREAM_PROBE_CELL` | (unattributed) | `=1` | gates one `[cell-transit]` line per `PlayerMovementController.CellId` change (old→new cell, position, reason tag) | print-only; low volume (only on actual cell crossings) | `PhysicsDiagnostics.ProbeCellEnabled` | | `ACDREAM_PROBE_CELLSET` | a6.p5 | `=1` | gates `PhysicsDiagnostics.LogCellSetBuild`, one `[cellset-build]` line per `BuildCellSetAndPickContaining` call (seed cell, sphere XY, candidate list) from `CellTransit.cs:1468` | print-only; builds a `StringBuilder` of the candidate id list only when the flag is on | `PhysicsDiagnostics.ProbeCellSetEnabled` | | `ACDREAM_PROBE_CELL_CACHE` | indoor walking phase d | `=1` | gates one `[cell-cache]` line per EnvCell first-cached in `PhysicsDataCache.CacheCellStruct` (poly counts, BSP root structure) | print-only; fires at most once per EnvCell (cache is no-op after first population); no DebugPanel mirror | `PhysicsDiagnostics.ProbeCellCacheEnabled` | | `ACDREAM_PROBE_CHILD_CELL` | c4 route 7 | `=1` | gates one `[child-cell]` line per Runtime committed-child canonical-cell write in `RuntimeLiveEntitySessionController`, `RuntimeEntityObjectLifetime`, `RuntimeEntityDirectory` (parent/child guid, old/new cell, cause tag) | print-only | `PhysicsDiagnostics.ProbeChildCellEnabled` | @@ -359,3 +371,10 @@ from the "must still exist" check. | `ACDREAM_PROBE_WALK_MISS` | Issue #83 indoor walkable-plane miss trace. Investigation closed; stripped 2026-08-24 (#435). | none | | `ACDREAM_WIRE_MESH` | Issue #337 F2 overlay upgrade to real physics-BSP polygon edges. Investigation closed; stripped 2026-08-24 (#435) — F2 reverted to its proxy-cylinder overlay. | none | | `ACDREAM_WIRE_RADIUS` | Companion radius knob for `ACDREAM_WIRE_MESH`. Stripped alongside it 2026-08-24 (#435). | none | +| `ACDREAM_A8_DUMP_PV` | Phase A8.F portal-frame visual-gate triage dump (camera-cell portal census + EXIT-PROJ/EXIT-CLIP/EXIT trace in `PortalVisibilityBuilder.Build`). Phase A8 closed; stripped 2026-08-24 (#435 part 2). | none | +| `ACDREAM_DUMP_CLOTHING` | Issue #37 humanoid-coat clothing/part-swap trace. #37 closed 2026-05-11; stripped 2026-08-24 (#435 part 2). | none | +| `ACDREAM_DUMP_EDGE_SLIDE` | Issue #32 L.2c edge-slide/cliff-slide branch trace (five `edge-slide:` lines). #32 closed 2026-08-07; stripped 2026-08-24 (#435 part 2). | none | +| `ACDREAM_DUMP_LIVE_SPAWNS` | Phase A8 indoor-visibility batch live-spawn/DROP trace. Phase A8 closed; stripped 2026-08-24 (#435 part 2). | none | +| `ACDREAM_DUMP_STEPUP` | L.2.3d/e/f step-up `stepup: enter/SUCCESS/FAILED` trace. Investigation closed; stripped 2026-08-24 (#435 part 2) — its content is still covered by the separate `[transit-fail-stepup]` line under `ACDREAM_DUMP_TRANSIT_FAIL`. | `ACDREAM_DUMP_TRANSIT_FAIL` | +| `ACDREAM_DUMP_VENDOR` | `[vendor-diag]` trace (~25 call sites) for two vendor-approach/split-stack regressions. Vendor campaign closed 2026-08-08; stripped 2026-08-24 (#435 part 2) along with its owner class `VendorDiagnostics.cs`. | none | +| `ACDREAM_DUMP_VITALS` | Issue #5 `PrivateUpdateVital`/`PlayerDescription`/parse-failure trace across 4 sites. #5 closed 2026-04-25; stripped 2026-08-24 (#435 part 2). | none | diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 879b9920..bbb7bb9d 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -562,8 +562,7 @@ internal sealed class SessionPlayerCompositionPhase d.EntityObjects, teardown, d.PlayerIdentity, - dormantLiveEntities, - d.Options.DumpLiveSpawns ? d.Log : null); + dormantLiveEntities); // 2026-08-08 vendor-approach root cause: the local player's // publication-chain host resolves moveto/sticky targets through // RuntimePhysicsState.ResolveObjectTableHost. Bind the graphical @@ -709,7 +708,6 @@ internal sealed class SessionPlayerCompositionPhase d.PlayerIdentity, deletion, dormantLiveEntities, - d.Options.DumpLiveSpawns ? d.Log : null, firstEntryDrive, acceptedPositionDrive); bindings.Adopt( diff --git a/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs b/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs index b2161cad..0b6dd15f 100644 --- a/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs +++ b/src/AcDream.App/Interaction/PlayerInteractionMovementSink.cs @@ -61,14 +61,6 @@ internal sealed class PlayerInteractionMovementSink( Height = approach.TargetHeight, }; - if (VendorDiagnostics.DumpVendorEnabled) - { - Console.WriteLine( - $"[vendor-diag] BeginApproach guid=0x{approach.Target.ServerGuid:X8} " - + $"movementType={movement.Type} distanceToObject={parameters.DistanceToObject} " - + $"canCharge={parameters.CanCharge} target=0x{movement.ObjectId:X8}"); - } - // PerformMovement cancels at its head. Do it explicitly before the // intent is armed so cancellation of the preceding move cannot clear // the new request; the internal second call is then a retail no-op. diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs index 27ef7851..35f40b98 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionController.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -181,11 +181,6 @@ internal sealed class SelectionInteractionController // ships with it. if (useImmediately && !_query.IsWieldedByPlayer(guid)) { - if (VendorDiagnostics.DumpVendorEnabled) - { - Console.WriteLine( - $"[vendor-diag] route=dblclick-world PickAndStoreSelection guid=0x{guid:X8} enqueue=Activate"); - } EnqueueIdentityBound( RuntimeQueuedInteractionKind.Activate, guid, @@ -221,11 +216,6 @@ internal sealed class SelectionInteractionController _toast?.Invoke("Nothing selected"); return; } - if (VendorDiagnostics.DumpVendorEnabled) - { - Console.WriteLine( - $"[vendor-diag] route=keyboard-use UseCurrentSelection guid=0x{selected:X8} enqueue=Use"); - } EnqueueIdentityBound( RuntimeQueuedInteractionKind.Use, selected, @@ -292,22 +282,10 @@ internal sealed class SelectionInteractionController bool ownedByPlayer = _items.IsOwnedByPlayer(serverGuid); bool useable = ownedByPlayer || _query.IsUseable(serverGuid); - if (VendorDiagnostics.DumpVendorEnabled) - { - Console.WriteLine( - $"[vendor-diag] RequestUse entry guid=0x{serverGuid:X8} ownedByPlayer={ownedByPlayer} useable={useable}"); - } - if (useable && _query.TryGetApproach(serverGuid, out InteractionApproach approach) && !approach.IsCloseRange) { - if (VendorDiagnostics.DumpVendorEnabled) - { - Console.WriteLine( - $"[vendor-diag] RequestUse guid=0x{serverGuid:X8} branch=approach-armed useRadius={approach.UseRadius} isCloseRange={approach.IsCloseRange}"); - } - // Genuinely out of range (a real walk, not just a turn) — // mirror SendPickup's arrival-gated shape: arm the transaction // on the approach token BEFORE the movement starts (so a @@ -327,17 +305,7 @@ internal sealed class SelectionInteractionController token.ControllerLifetime, token.ApproachGeneration), out _); - if (VendorDiagnostics.DumpVendorEnabled) - { - Console.WriteLine( - $"[vendor-diag] TryArmPostArrivalUse guid=0x{serverGuid:X8} armed={armed} approachToken=({token.ControllerLifetime},{token.ApproachGeneration})"); - } }); - if (VendorDiagnostics.DumpVendorEnabled) - { - Console.WriteLine( - $"[vendor-diag] BeginApproach-result guid=0x{serverGuid:X8} started={started} armed={armed} stopDistance={approach.UseRadius} target=0x{approach.Target.ServerGuid:X8}"); - } if (!started || !armed) { // Release whatever got captured (or the caller's own @@ -360,11 +328,6 @@ internal sealed class SelectionInteractionController // — keep retail's immediate send; ACE's own "already within use // distance" branch (Player_Move.cs:65-87) calls back synchronously, // so there is no arrival gap to race here. - if (VendorDiagnostics.DumpVendorEnabled) - { - Console.WriteLine( - $"[vendor-diag] RequestUse guid=0x{serverGuid:X8} branch=immediate-dispatch ownedByPlayer={ownedByPlayer} useable={useable}"); - } RuntimeInteractionDispatchResult result = _transactions.TryDispatchUse( serverGuid, @@ -373,11 +336,6 @@ internal sealed class SelectionInteractionController reservation, _transport, out uint sequence); - if (VendorDiagnostics.DumpVendorEnabled) - { - Console.WriteLine( - $"[vendor-diag] RequestUse guid=0x{serverGuid:X8} TryDispatchUse verdict={result} seq={sequence}"); - } if (result == RuntimeInteractionDispatchResult.NotInWorld) _toast?.Invoke("Not in world"); if (result == RuntimeInteractionDispatchResult.Dispatched) @@ -609,22 +567,6 @@ internal sealed class SelectionInteractionController RuntimePendingUse pending, bool accepted) { - if (VendorDiagnostics.DumpVendorEnabled) - { - // Diagnostic-only re-query — TryGetApproach is a pure read with - // no side effects, so an extra call here (gated off in - // production) cannot change RequestUse's own dispatch outcome. - string distanceText = "n/a"; - if (_query.TryGetApproach(pending.ServerGuid, out InteractionApproach diagApproach)) - { - float dx = diagApproach.Target.Entity.Position.X - diagApproach.Player.Position.X; - float dy = diagApproach.Target.Entity.Position.Y - diagApproach.Player.Position.Y; - distanceText = MathF.Sqrt(dx * dx + dy * dy).ToString("F2"); - } - Console.WriteLine( - $"[vendor-diag] HandleUseApproachCompletion guid=0x{pending.ServerGuid:X8} accepted={accepted} playerToTargetDist={distanceText}"); - } - if (!accepted) { pending.Reservation?.CancelBeforeDispatch(); diff --git a/src/AcDream.App/Interaction/WorldSelectionQuery.cs b/src/AcDream.App/Interaction/WorldSelectionQuery.cs index 9acbd662..883504bd 100644 --- a/src/AcDream.App/Interaction/WorldSelectionQuery.cs +++ b/src/AcDream.App/Interaction/WorldSelectionQuery.cs @@ -571,12 +571,6 @@ internal sealed class WorldSelectionQuery bool haveSpawn = _liveEntities.TryGetSnapshot(serverGuid, out var spawn); bool fromWire = haveSpawn && spawn.UseRadius is > 0f; float radius = fromWire ? spawn.UseRadius!.Value : DefaultUseRadius; - if (VendorDiagnostics.DumpVendorEnabled) - { - Console.WriteLine( - $"[vendor-diag] GetUseRadius guid=0x{serverGuid:X8} radius={radius} " - + $"source={(fromWire ? "wire" : "fallback-0.6")}"); - } return radius; } } diff --git a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs index 665d757c..d3d618fb 100644 --- a/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs +++ b/src/AcDream.App/Rendering/DatLiveEntityProjectionMaterializer.cs @@ -58,17 +58,6 @@ internal sealed class DatLiveEntityProjectionMaterializer /// private readonly RuntimeWorldTransitState _transit; - private int _received; - private int _hydrated; - private int _noPosition; - private int _noSetup; - private int _missingSetup; - private int _noMesh; - private int _noCycle; - private int _zeroFramerate; - private int _singleFrame; - private int _missingPartFrames; - public DatLiveEntityProjectionMaterializer( RuntimeOptions options, IDatReaderWriter dats, @@ -116,16 +105,6 @@ internal sealed class DatLiveEntityProjectionMaterializer public void ResetSessionState() { - _received = 0; - _hydrated = 0; - _noPosition = 0; - _noSetup = 0; - _missingSetup = 0; - _noMesh = 0; - _noCycle = 0; - _zeroFramerate = 0; - _singleFrame = 0; - _missingPartFrames = 0; } public bool TryMaterialize( @@ -157,18 +136,10 @@ internal sealed class DatLiveEntityProjectionMaterializer if (appearanceUpdate is not null && expectedRecord is null) return false; - _received++; - bool dumpLiveSpawns = _options.DumpLiveSpawns; - DumpSpawn(canonicalSpawn, dumpLiveSpawns); - if (!_origin.IsKnown) return false; if (canonicalSpawn.Position is null || canonicalSpawn.SetupTableId is null) { - if (canonicalSpawn.Position is null) - _noPosition++; - else - _noSetup++; return false; } @@ -216,13 +187,6 @@ internal sealed class DatLiveEntityProjectionMaterializer _collisionAssets.CacheSetup(canonicalSpawn.SetupTableId.Value, setup); if (setup is null) { - _missingSetup++; - if (dumpLiveSpawns) - { - Console.WriteLine( - $"live: DROP setup dat 0x{canonicalSpawn.SetupTableId.Value:X8} missing " - + $"(guid=0x{canonicalSpawn.Guid:X8})"); - } return false; } @@ -248,14 +212,6 @@ internal sealed class DatLiveEntityProjectionMaterializer List flattened = [.. SetupMesh.Flatten(setup, idleFrame)]; IReadOnlyList animPartChanges = canonicalSpawn.AnimPartChanges ?? Array.Empty(); - bool dumpClothing = _options.DumpClothing && setup.Parts.Count >= 10; - DumpClothingHeader( - canonicalSpawn, - setup, - flattened, - idleFrame, - animPartChanges, - dumpClothing); foreach (CreateObject.AnimPartChange change in animPartChanges) { @@ -273,17 +229,14 @@ internal sealed class DatLiveEntityProjectionMaterializer baseId => ResolveCollisionPart(baseId)); if (_options.RetailCloseDegrades && IsIssue47HumanoidSetup(setup)) - ApplyRetailCloseDegrades(flattened, dumpClothing); + ApplyRetailCloseDegrades(flattened); IReadOnlyList textureChanges = canonicalSpawn.TextureChanges ?? Array.Empty(); Dictionary>? surfaceOverrides = ResolveSurfaceOverrides( - canonicalSpawn, flattened, - textureChanges, - dumpClothing, - dumpLiveSpawns); + textureChanges); float scale = canonicalSpawn.ObjScale ?? 1f; Matrix4x4 scaleMatrix = Matrix4x4.CreateScale(scale); @@ -294,7 +247,6 @@ internal sealed class DatLiveEntityProjectionMaterializer var indexedPartAvailable = new bool[flattened.Count]; var animatedPartTemplate = new LiveAnimationPartTemplate[flattened.Count]; var bounds = new LocalBoundsAccumulator(); - int clothingTriangles = 0; for (int partIndex = 0; partIndex < flattened.Count; partIndex++) { @@ -318,23 +270,10 @@ internal sealed class DatLiveEntityProjectionMaterializer drawable); if (gfx is null) { - if (dumpClothing) - Console.WriteLine($" EMIT part={partIndex:D2} gfx=0x{part.GfxObjId:X8} GFXOBJ_DAT_MISSING -> 0 tris"); continue; } _collisionAssets.CacheGfxObj(part.GfxObjId, gfx); - if (dumpClothing) - { - var subMeshes = GfxObjMesh.Build(gfx, _dats); - int triangles = 0; - foreach (var subMesh in subMeshes) - triangles += subMesh.Indices.Length / 3; - clothingTriangles += triangles; - Console.WriteLine( - $" EMIT part={partIndex:D2} gfx=0x{part.GfxObjId:X8} " - + $"subMeshes={subMeshes.Count} tris={triangles}"); - } if (GfxObjBounds.Get(gfx) is { } partBounds) bounds.Add(transform, partBounds); @@ -346,21 +285,8 @@ internal sealed class DatLiveEntityProjectionMaterializer if (meshRefs.Count == 0) { - _noMesh++; - if (dumpLiveSpawns) - { - Console.WriteLine( - $"live: DROP no mesh refs from setup 0x{canonicalSpawn.SetupTableId.Value:X8} " - + $"(guid=0x{canonicalSpawn.Guid:X8})"); - } return false; } - if (dumpClothing) - { - Console.WriteLine( - $" TOTAL tris={clothingTriangles} meshRefs={meshRefs.Count} " - + $"(parts.Count={flattened.Count})"); - } PaletteOverride? paletteOverride = CreatePaletteOverride(canonicalSpawn); PartOverride[] partOverrides = CreatePartOverrides(animPartChanges); @@ -456,7 +382,6 @@ internal sealed class DatLiveEntityProjectionMaterializer indexedPartAvailable, animatedPartTemplate, bounds, - dumpLiveSpawns, expectedCreateIntegrationVersion, synchronizeAnimation: supersessionRecovery); } @@ -477,9 +402,7 @@ internal sealed class DatLiveEntityProjectionMaterializer return slotZeroId; } - private void ApplyRetailCloseDegrades( - List parts, - bool dumpClothing) + private void ApplyRetailCloseDegrades(List parts) { for (int partIndex = 0; partIndex < parts.Count; partIndex++) { @@ -495,33 +418,13 @@ internal sealed class DatLiveEntityProjectionMaterializer } parts[partIndex] = new MeshRef(resolvedId, part.PartTransform); - if (dumpClothing) - { - Console.WriteLine( - $" DEGRADE part={partIndex:D2} gfx=0x{part.GfxObjId:X8} " - + $"-> close=0x{resolvedId:X8}"); - } } } private Dictionary>? ResolveSurfaceOverrides( - WorldSession.EntitySpawn spawn, IReadOnlyList parts, - IReadOnlyList textureChanges, - bool dumpClothing, - bool dumpLiveSpawns) + IReadOnlyList textureChanges) { - if (dumpClothing) - { - Console.WriteLine($" TextureChanges count={textureChanges.Count}"); - foreach (CreateObject.TextureChange change in textureChanges) - { - Console.WriteLine( - $" TC part={change.PartIndex:D2} oldTex=0x{change.OldTexture:X8} " - + $"-> newTex=0x{change.NewTexture:X8}"); - } - } - if (textureChanges.Count == 0) return null; @@ -536,8 +439,6 @@ internal sealed class DatLiveEntityProjectionMaterializer oldToNew[change.OldTexture] = change.NewTexture; } - bool statueDiagnostic = dumpLiveSpawns - && spawn.Name?.Contains("Statue", StringComparison.OrdinalIgnoreCase) == true; var result = new Dictionary>(); for (int partIndex = 0; partIndex < parts.Count; partIndex++) { @@ -547,12 +448,6 @@ internal sealed class DatLiveEntityProjectionMaterializer GfxObj? gfx = _dats.Get(parts[partIndex].GfxObjId); if (gfx is null) { - if (statueDiagnostic) - { - Console.WriteLine( - $"live: [STATUE] resolve part={partIndex} " - + $"GfxObj 0x{parts[partIndex].GfxObjId:X8} missing"); - } continue; } _collisionAssets.CacheGfxObj(parts[partIndex].GfxObjId, gfx); @@ -729,7 +624,6 @@ internal sealed class DatLiveEntityProjectionMaterializer IReadOnlyList indexedPartAvailable, IReadOnlyList animatedPartTemplate, LocalBoundsAccumulator bounds, - bool dumpLiveSpawns, ulong expectedCreateIntegrationVersion, bool synchronizeAnimation) { @@ -835,7 +729,6 @@ internal sealed class DatLiveEntityProjectionMaterializer _worldEvents.UpsertCurrent(snapshot); if (_runtime.TryMarkWorldSpawnPublished(spawn.Guid)) _worldEvents.FireEntitySpawned(snapshot); - _hydrated++; if (!_runtime.IsCurrentCreateIntegration( expectedRecord, @@ -921,18 +814,6 @@ internal sealed class DatLiveEntityProjectionMaterializer return false; } - if (dumpLiveSpawns && _received % 20 == 0) - { - Console.WriteLine( - $"live: animated={_runtime.AnimationRuntimeCount} " - + $"animReject: noCycle={_noCycle} fr0={_zeroFramerate} " - + $"1frame={_singleFrame} partFrames={_missingPartFrames}"); - Console.WriteLine( - $"live: summary recv={_received} hydrated={_hydrated} " - + $"drops: noPos={_noPosition} noSetup={_noSetup} " - + $"setupMissing={_missingSetup} noMesh={_noMesh}"); - } - return _runtime.IsCurrentCreateIntegration( expectedRecord, expectedCreateIntegrationVersion); @@ -961,18 +842,6 @@ internal sealed class DatLiveEntityProjectionMaterializer spawn, idleCycle); } - if (!retainedAnimation) - { - if (idleCycle is null) - _noCycle++; - else if (idleCycle.Framerate == 0f) - _zeroFramerate++; - else if (idleCycle.HighFrame <= idleCycle.LowFrame) - _singleFrame++; - else if (idleCycle.Animation.PartFrames.Count <= 1) - _missingPartFrames++; - } - if (!retainedAnimation && idleCycle is not null && idleCycle.Framerate != 0f @@ -1159,56 +1028,6 @@ internal sealed class DatLiveEntityProjectionMaterializer : null; } - private void DumpSpawn(WorldSession.EntitySpawn spawn, bool enabled) - { - if (!enabled) - return; - - string position = spawn.Position is { } p - ? $"({p.PositionX:F1},{p.PositionY:F1},{p.PositionZ:F1})@0x{p.LandblockId:X8}" - : "no-pos"; - string setup = spawn.SetupTableId is { } setupId - ? $"0x{setupId:X8}" - : "no-setup"; - string physicsTable = spawn.Physics?.PhysicsScriptTableId is { } tableId - ? $"0x{tableId:X8}" - : "no-petable"; - string name = spawn.Name is { Length: > 0 } foundName - ? $"\"{foundName}\"" - : "no-name"; - string itemType = spawn.ItemType is { } foundItemType - ? $"0x{foundItemType:X8}" - : "no-itemtype"; - Console.WriteLine( - $"live: spawn guid=0x{spawn.Guid:X8} name={name} setup={setup} pos={position} " - + $"petable={physicsTable} itemType={itemType} " - + $"animParts={spawn.AnimPartChanges?.Count ?? 0} " - + $"texChanges={spawn.TextureChanges?.Count ?? 0} " - + $"subPalettes={spawn.SubPalettes?.Count ?? 0}"); - } - - private void DumpClothingHeader( - WorldSession.EntitySpawn spawn, - Setup setup, - IReadOnlyList flattened, - AnimationFrame? idleFrame, - IReadOnlyList changes, - bool enabled) - { - if (!enabled) - return; - - Console.WriteLine( - $"\n=== DUMP_CLOTHING: guid=0x{spawn.Guid:X8} name='{spawn.Name}' " - + $"setup=0x{setup.Id:X8} setup.Parts.Count={setup.Parts.Count} " - + $"flatten.Count={flattened.Count} APC={changes.Count} ==="); - foreach (CreateObject.AnimPartChange change in changes) - Console.WriteLine($" APC part={change.PartIndex:D2} -> gfx=0x{change.NewModelId:X8}"); - Console.WriteLine( - $" basePalette=0x{spawn.BasePaletteId ?? 0:X8} " - + $"subPalettes={spawn.SubPalettes?.Count ?? 0}"); - } - private static bool IsIssue47HumanoidSetup(Setup setup) { if (setup.Parts.Count != 34) diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index 2edaaf2a..a9164297 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -265,12 +265,6 @@ public static class PortalVisibilityBuilder // stands near a doorway plane). private const float SeedInPlaneEpsilon = 0.0002f; - // TEMP diagnostic (Phase A8.F visual-gate triage; strip after): ACDREAM_A8_DUMP_PV=1 dumps the - // local→NDC→clipped portal geometry for the first 2 Build calls per distinct camera cell. - private static readonly bool s_pvDump = - Environment.GetEnvironmentVariable("ACDREAM_A8_DUMP_PV") == "1"; - private static readonly Dictionary s_pvDumpCount = new(); - /// /// #120 observable: total convergence-tripwire firings across both the /// interior and the exterior look-in propagation. @@ -386,36 +380,6 @@ public static class PortalVisibilityBuilder int churnReenqueues = 0; var churnReciprocal = churnProbe ? new System.Text.StringBuilder(256) : null; - bool pvDump = false; - if (s_pvDump) - { - lock (s_pvDumpCount) - { - s_pvDumpCount.TryGetValue(cameraCell.CellId, out int dc); - if (dc < 2) { s_pvDumpCount[cameraCell.CellId] = dc + 1; pvDump = true; } - } - if (pvDump) - { - Console.WriteLine($"[pv-dump] camCell=0x{cameraCell.CellId:X8} portals={cameraCell.Portals.Count} polyLists={cameraCell.PortalPolygons.Count} vp[M11={viewProj.M11:F3} M22={viewProj.M22:F3} M33={viewProj.M33:F3} M34={viewProj.M34:F3} M43={viewProj.M43:F3} M44={viewProj.M44:F3}]"); - // Camera-cell portal census (A8.F triage 2026-05-29): report, for EVERY - // portal, the exact inputs the BFS guards read — BEFORE the guards run, so - // a portal the loop silently `continue`s past is still visible here. An - // empty OUTSIDEVIEW can then be traced to the precise gate: polyLen<3 (empty - // polygon from EnvCellLandblockBuildBuilder), interiorSide=false (camera back-facing the - // portal — a legitimately-empty result, not a bug), or (if both OK) a - // downstream projection/clip failure shown by the EXIT-PROJ/EXIT-CLIP lines. - for (int ci = 0; ci < cameraCell.Portals.Count; ci++) - { - int plen = ci < cameraCell.PortalPolygons.Count - ? (cameraCell.PortalPolygons[ci]?.Length ?? -1) : -2; - bool hasPlane = ci < cameraCell.ClipPlanes.Count; - bool interiorSide = !hasPlane || CameraOnInteriorSide(cameraCell, ci, cameraPos); - var n = hasPlane ? cameraCell.ClipPlanes[ci].Normal : Vector3.Zero; - Console.WriteLine($"[pv-dump] CAMPORTAL[{ci}] other=0x{cameraCell.Portals[ci].OtherCellId:X4} polyLen={plen} hasPlane={hasPlane} interiorSide={interiorSide} planeN=({n.X:F3},{n.Y:F3},{n.Z:F3})"); - } - } - } - // T2 (BR-4): retail's growth propagation is IN PLACE, never by re-enqueue // — PView::AddViewToPortals (Ghidra 0x005a52d0, pc:433446): first // discovery enqueues via InsCellTodoList; growth into a cell whose @@ -481,8 +445,6 @@ public static class PortalVisibilityBuilder continue; } - bool dx = pvDump && cell.Portals[i].OtherCellId == 0xFFFF; - // (R-A2b Phase 1 pin, throwaway) Log the side-test inputs for EVERY portal so a back-portal // traversal (cell=0x..0173 p->0x0171) can be attributed to the side test. // Strip with the rest of the [pv-trace] apparatus. @@ -505,7 +467,6 @@ public static class PortalVisibilityBuilder && !CameraOnInteriorSide(cell, i, cameraPos)) { trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=side"); - if (dx) Console.WriteLine($"[pv-dump] EXIT-CULLED(side) cell=0x{cell.CellId:X8} p{i} localN={poly.Length} hasClipPlane={(i < cell.ClipPlanes.Count)}"); continue; } @@ -526,8 +487,6 @@ public static class PortalVisibilityBuilder endCount - processedCount, clippedRegion, out int clipVerts); - if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipN={clipVerts} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2})"); - if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}"); // Empty clip = no flood through this portal, period — retail's empty-GetClip rule // (polyClipFinish <3 survivors → reject; ClipPortals adds no view). The @@ -544,13 +503,6 @@ public static class PortalVisibilityBuilder if (portal.OtherCellId == 0xFFFF) { - if (pvDump) - { - Console.WriteLine($"[pv-dump] EXIT cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipVerts={clipVerts} clipPolys={clippedRegion.Count}"); - Console.WriteLine($"[pv-dump] local=[{string.Join(" ", System.Array.ConvertAll(poly, v => $"({v.X:F2},{v.Y:F2},{v.Z:F2})"))}]"); - foreach (var cp in clippedRegion) - Console.WriteLine($"[pv-dump] clipped({cp.Vertices.Length})=[{string.Join(" ", System.Array.ConvertAll((Vector2[])cp.Vertices, v => $"({v.X:F3},{v.Y:F3})"))}]"); - } // Exit portal -> outdoors visible through this (clipped) opening. // OutsideView gates DRAWN color (terrain/sky/scissor), and the // shell that rasterizes this aperture draws +drawLiftZ above @@ -688,9 +640,6 @@ public static class PortalVisibilityBuilder ProcessCellPortals(cell, 0); } - if (pvDump) - Console.WriteLine($"[pv-dump] OUTSIDEVIEW polys={frame.OutsideView.Polygons.Count} bfsCellViews={frame.CellViews.Count} crossBldg={frame.CrossBuildingViews.Count}"); - // Phase U.4c flap probe (ACDREAM_PROBE_FLAP) — read-only per-frame snapshot of the // root cell's per-portal side-test + projection + the frame's exit/visible counts. if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index d4c0e500..22e6679a 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -42,14 +42,16 @@ public sealed record RuntimeOptions( string? LivePass, bool DevTools, bool UncappedRendering, + /// #435 kept deliberately: NOT a spent probe. The canonical + /// nine-stop soak (`tools/run-connected-r6-soak.ps1`) hard-gates on the + /// `move-truth OUT` records this emits — it is the automated proof that + /// production input produced outbound movement traffic. bool DumpMoveTruth, bool DumpSky, bool NoAudio, int HidePartIndex, bool RetailCloseDegrades, bool DumpSceneryZ, - bool DumpLiveSpawns, - bool DumpClothing, int? LegacyStreamRadius, bool RetailUi, /// Campaign CC slice CC4: interim env/test-only seam that opens @@ -157,8 +159,6 @@ public sealed record RuntimeOptions( // only for before/after diagnostic comparisons. RetailCloseDegrades: !string.Equals(env("ACDREAM_RETAIL_CLOSE_DEGRADES"), "0", StringComparison.Ordinal), DumpSceneryZ: IsExactlyOne(env("ACDREAM_DUMP_SCENERY_Z")), - DumpLiveSpawns: IsExactlyOne(env("ACDREAM_DUMP_LIVE_SPAWNS")), - DumpClothing: IsExactlyOne(env("ACDREAM_DUMP_CLOTHING")), // Legacy override for ACDREAM_STREAM_RADIUS. Caller applies it on // top of the quality preset's radii. Null when unset or invalid. LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")), diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 5a327efb..a0515a53 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -525,11 +525,6 @@ public sealed class ItemInteractionController : IDisposable switch (mode) { case InteractionModeKind.Use: - if (VendorDiagnostics.DumpVendorEnabled) - { - Console.WriteLine( - $"[vendor-diag] route=target-mode-click OfferPrimaryClick(Use) guid=0x{targetGuid:X8}"); - } ClearTargetMode(); accepted = ActivateItem(targetGuid); break; @@ -562,11 +557,6 @@ public sealed class ItemInteractionController : IDisposable /// public bool UseSelectedOrEnterMode(uint selectedObjectId) { - if (VendorDiagnostics.DumpVendorEnabled) - { - Console.WriteLine( - $"[vendor-diag] route=toolbar-or-queued-use UseSelectedOrEnterMode guid=0x{selectedObjectId:X8}"); - } if (selectedObjectId != 0) return ActivateItem(selectedObjectId); return _interactionState.EnterUse(); @@ -666,11 +656,6 @@ public sealed class ItemInteractionController : IDisposable public bool ActivateItem(uint itemGuid) { - if (VendorDiagnostics.DumpVendorEnabled) - { - Console.WriteLine( - $"[vendor-diag] entry:ActivateItem guid=0x{itemGuid:X8} isTargetModeActive={IsTargetModeActive}"); - } if (itemGuid == 0) return false; if (IsTargetModeActive) @@ -997,11 +982,6 @@ public sealed class ItemInteractionController : IDisposable /// public bool ExecuteConfirmedUse(uint objectId) { - if (VendorDiagnostics.DumpVendorEnabled) - { - Console.WriteLine( - $"[vendor-diag] route=confirmed-use ExecuteConfirmedUse guid=0x{objectId:X8}"); - } if (objectId == 0u || (_requestUse is null && _sendUse is null)) return false; if (!EnsureInventoryRequestReady()) diff --git a/src/AcDream.App/UI/Layout/SelectedObjectController.cs b/src/AcDream.App/UI/Layout/SelectedObjectController.cs index 7076e794..1f4ba9c7 100644 --- a/src/AcDream.App/UI/Layout/SelectedObjectController.cs +++ b/src/AcDream.App/UI/Layout/SelectedObjectController.cs @@ -377,13 +377,6 @@ public sealed class SelectedObjectController : IRetainedPanelController ? $"{stackSize} {objectName}" : objectName; - if (VendorDiagnostics.DumpVendorEnabled) - { - Console.WriteLine( - $"[vendor-diag] ApplySelection guid=0x{g:X8} stackSizeOperand={stackSize} " - + $"objectName={objectName ?? "null"} builtLabel={_currentName ?? "null"}"); - } - // ── 3. Selection overlay: brief flash (retail container ObjectSelected // = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ────────────── SetOverlayState(stackSize > 1u @@ -410,18 +403,6 @@ public sealed class SelectedObjectController : IRetainedPanelController _splitQuantity.Reset(stackSize, initialValue: seed); if (_stackSizeEntry is not null) _stackSizeEntry.Visible = true; if (_stackSizeSlider is not null) _stackSizeSlider.Visible = true; - if (VendorDiagnostics.DumpVendorEnabled) - { - Console.WriteLine( - $"[vendor-diag] ApplySelection guid=0x{g:X8} sliderVisible=true " - + $"isVendorSplitExempt={vendorSplitExempt} maxSplitSize={stackSize} seed={seed}"); - } - } - else if (VendorDiagnostics.DumpVendorEnabled) - { - Console.WriteLine( - $"[vendor-diag] ApplySelection guid=0x{g:X8} sliderVisible=false " - + $"failingPredicate=stackSize<=1u stackSize={stackSize}"); } // ── 4. Health: query, and show the meter only if real health is already known. diff --git a/src/AcDream.App/World/LiveEntityDeletionController.cs b/src/AcDream.App/World/LiveEntityDeletionController.cs index bebeecfd..8bc67bf0 100644 --- a/src/AcDream.App/World/LiveEntityDeletionController.cs +++ b/src/AcDream.App/World/LiveEntityDeletionController.cs @@ -21,15 +21,13 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink private readonly ILiveEntityTeardownCoordinator _teardown; private readonly ILocalPlayerIdentitySource _identity; private readonly DormantLiveEntityStore _dormant; - private readonly Action? _diagnostic; public LiveEntityDeletionController( LiveEntityRuntime runtime, RuntimeEntityObjectLifetime entityObjects, ILiveEntityTeardownCoordinator teardown, ILocalPlayerIdentitySource identity, - DormantLiveEntityStore? dormant = null, - Action? diagnostic = null) + DormantLiveEntityStore? dormant = null) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _entityObjects = entityObjects @@ -37,7 +35,6 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink _teardown = teardown ?? throw new ArgumentNullException(nameof(teardown)); _identity = identity ?? throw new ArgumentNullException(nameof(identity)); _dormant = dormant ?? new DormantLiveEntityStore(); - _diagnostic = diagnostic; } /// @@ -64,13 +61,6 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink if (removed) { _dormant.RemoveExact(delete); - _diagnostic?.Invoke( - $"live: delete guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}"); - } - else if (removedDormant) - { - _diagnostic?.Invoke( - $"live: delete dormant guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}"); } return removed || removedDormant; } @@ -95,8 +85,6 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink return false; _dormant.Retain(snapshot); - _diagnostic?.Invoke( - $"live: dormant guid=0x{candidate.ServerGuid:X8} instSeq={candidate.Generation}"); return true; } } diff --git a/src/AcDream.App/World/LiveEntityHydrationController.cs b/src/AcDream.App/World/LiveEntityHydrationController.cs index c23c6e45..a21404a0 100644 --- a/src/AcDream.App/World/LiveEntityHydrationController.cs +++ b/src/AcDream.App/World/LiveEntityHydrationController.cs @@ -180,7 +180,6 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded private readonly ILocalPlayerIdentitySource _identity; private readonly LiveEntityDeletionController _deletion; private readonly DormantLiveEntityStore _dormant; - private readonly Action? _diagnostic; /// /// C3c: the graphical first-entry drive pump — pumped at the end of each /// Create transaction so a fresh residence drives its conductor @@ -219,7 +218,6 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded ILocalPlayerIdentitySource identity, LiveEntityDeletionController deletion, DormantLiveEntityStore? dormant = null, - Action? diagnostic = null, RuntimeFirstEntryDriveController? firstEntry = null, RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null) { @@ -236,7 +234,6 @@ internal sealed class LiveEntityHydrationController : ILiveEntityLandblockLoaded _identity = identity ?? throw new ArgumentNullException(nameof(identity)); _deletion = deletion ?? throw new ArgumentNullException(nameof(deletion)); _dormant = dormant ?? new DormantLiveEntityStore(); - _diagnostic = diagnostic; _firstEntry = firstEntry; _acceptedPositionDrive = acceptedPositionDrive; } @@ -465,11 +462,6 @@ AppearanceSynchronization: ChildUnparentDisposition disposition = _relationships.OnChildBecameUnparented(update.Guid); bool accepted = disposition is not ChildUnparentDisposition.Superseded; - if (accepted) - { - _diagnostic?.Invoke( - $"live: pickup guid=0x{update.Guid:X8} instSeq={update.InstanceSequence} posSeq={update.PositionSequence}"); - } return accepted; } @@ -601,7 +593,6 @@ AppearanceSynchronization: if (candidates.Count == 0) return; - int projected = 0; lock (_datLock) { foreach (RuntimeEntityRecord candidate in candidates) @@ -623,26 +614,21 @@ AppearanceSynchronization: candidate.Snapshot, LiveProjectionPurpose.CreateSupersessionRecovery)) { - projected++; projectedCanonicalAppearance = true; } } else if (record?.WorldEntity is not null && record.InitialHydrationCompleted) { - if (_runtime.RebucketLiveEntity( - record.ServerGuid, - record.ProjectionCellId)) - { - projected++; - } + _runtime.RebucketLiveEntity( + record.ServerGuid, + record.ProjectionCellId); } else if (ProjectExact( candidate, candidate.Snapshot, LiveProjectionPurpose.SpatialRecovery)) { - projected++; projectedCanonicalAppearance = true; } @@ -658,21 +644,14 @@ AppearanceSynchronization: if (record is not null && _runtime.IsCurrentRecord(record) - && record.AppearanceProjectionSynchronizationPending - && SynchronizeAppearance( - record, - record.ObjDescAuthorityVersion)) + && record.AppearanceProjectionSynchronizationPending) { - projected++; + SynchronizeAppearance( + record, + record.ObjDescAuthorityVersion); } } } - - if (projected > 0) - { - _diagnostic?.Invoke( - $"live: re-projected {projected} server object(s) into landblock 0x{loadedLandblockId:X8}"); - } } public bool EnsureWorldOrigin( diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 81d3160f..554008fc 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -822,13 +822,6 @@ public static class GameEventWiring registrar.Register(GameEventType.ApproachVendor, e => { var p = VendorApproach.TryParse(e.Payload.Span); - if (VendorDiagnostics.DumpVendorEnabled) - { - Console.WriteLine( - $"[vendor-diag] ApproachVendor(0x0062) inbound parsed={p is not null} " - + $"vendorGuid={(p is null ? "n/a" : $"0x{p.Value.VendorGuid:X8}")} " - + $"itemCount={(p is null ? "n/a" : p.Value.Items.Count.ToString())}"); - } if (p is null) return; var profile = new VendorShopProfile( @@ -846,14 +839,6 @@ public static class GameEventWiring for (int i = 0; i < shopItems.Length; i++) { VendorApproach.ItemProfile item = p.Value.Items[i]; - if (VendorDiagnostics.DumpVendorEnabled && i < 5) - { - Console.WriteLine( - $"[vendor-diag] ApproachVendor wire-item[{i}] guid=0x{item.ItemGuid:X8} " - + $"name={item.Desc.Name ?? "null"} " - + $"descStackSize={(item.Desc.StackSize is { } ds ? ds.ToString() : "null")} " - + $"stackSizeMax={(item.Desc.StackSizeMax is { } sm ? sm.ToString() : "null")}"); - } shopItems[i] = new VendorShopItem( item.ItemGuid, item.StackSize, @@ -950,12 +935,6 @@ public static class GameEventWiring registrar.Register(GameEventType.UseDone, e => { uint? err = GameEvents.ParseUseDone(e.Payload.Span); - if (VendorDiagnostics.DumpVendorEnabled) - { - Console.WriteLine( - $"[vendor-diag] UseDone(0x01C7) inbound parsed={err is not null} " - + $"err={(err is null ? "n/a" : $"0x{err.Value:X4}")}"); - } if (err is null) return; // Already the diagnostics-only log line SHOULD-FIX 4 asks for — // it fires unconditionally, so an unmapped code below stays @@ -1038,12 +1017,9 @@ public static class GameEventWiring // player this is the authoritative source (the per-item // SpellBook flag in IdentifyObjectResponse is for caster // items / scrolls only). - bool dumpPd = Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1"; registrar.Register(GameEventType.PlayerDescription, e => { var p = PlayerDescriptionParser.TryParse(e.Payload.Span); - if (dumpPd) - Console.WriteLine($"vitals: PlayerDescription body.len={e.Payload.Length} parsed={(p is null ? "NULL" : $"vec={p.Value.VectorFlags} attrs={p.Value.Attributes.Count} spells={p.Value.Spells.Count}")}"); if (p is null) return; // R3: a trailer-truncated parse carries zero placeholder option @@ -1106,8 +1082,6 @@ public static class GameEventWiring if (attr.Current is uint cur) { // Vital entry (id 7/8/9) — has absolute current. - if (dumpPd) - Console.WriteLine($"vitals: PD-vital id={attr.AtType} ranks={attr.Ranks} start={attr.Start} cur={cur}"); localPlayer.OnVitalUpdate( vitalId: attr.AtType, ranks: attr.Ranks, @@ -1120,8 +1094,6 @@ public static class GameEventWiring // Primary attribute (id 1..6) — Endurance+Self feed // the vital max formula (Endurance/2 for Health, // Endurance for Stamina, Self for Mana). - if (dumpPd) - Console.WriteLine($"vitals: PD-attr id={attr.AtType} ranks={attr.Ranks} start={attr.Start}"); localPlayer.OnAttributeUpdate( atType: attr.AtType, ranks: attr.Ranks, @@ -1174,10 +1146,6 @@ public static class GameEventWiring int total = (int)(formulaBonus + s.Init + s.Ranks); if (s.SkillId == 24u) runSkill = total; else if (s.SkillId == 22u) jumpSkill = total; - - if (dumpPd) - Console.WriteLine( - $"vitals: PD-skill id={s.SkillId} init={s.Init} ranks={s.Ranks} formulaBonus={formulaBonus} total={total}"); } if (runSkill >= 0 || jumpSkill >= 0) onSkillsUpdated?.Invoke(runSkill, jumpSkill); diff --git a/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs b/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs index 765b97fe..8fdb6fdf 100644 --- a/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs +++ b/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs @@ -447,7 +447,7 @@ public static class PlayerDescriptionParser } } } - catch (FormatException ex) + catch (FormatException) { // Trailer corrupted — keep what we have and flag it. Once // Tasks 3-9 add list reads inside this try block, partial @@ -455,8 +455,6 @@ public static class PlayerDescriptionParser // them so they can ignore the trailer if they need all-or- // nothing semantics. trailerTruncated = true; - if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1") - System.Console.WriteLine($"PlayerDescriptionParser: trailer FormatException at pos={pos}/{payload.Length}: {ex.Message}"); } return new Parsed( @@ -466,12 +464,9 @@ public static class PlayerDescriptionParser shortcuts, hotbarSpells, desiredComps, spellbookFilters, gameplayOptions, inventory, equipped, trailerTruncated); } - catch (FormatException ex) + catch (FormatException) { // Truncation mid-walk — return null so caller knows parse failed. - // Diagnostic when ACDREAM_DUMP_VITALS=1 surfaces the failure point. - if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1") - System.Console.WriteLine($"PlayerDescriptionParser: FormatException at pos={pos}/{payload.Length}: {ex.Message}"); return null; } } diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index c513e699..2b83fc50 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -780,15 +780,12 @@ public sealed class WorldSession : IDisposable // Issue #5 diagnostics (env-var-gated): // ACDREAM_DUMP_OPCODES=1 → log first occurrence of each unhandled opcode - // ACDREAM_DUMP_VITALS=1 → log every PrivateUpdateVital(Current) parse // ACDREAM_DUMP_APPEARANCE=1 → log every 0xF625 ObjDescEvent + 0xF7DB UpdateObject // with body len, target guid, hex preview. Used to // debug remote-player appearance asymmetry (retail // observer in acdream renders wrong skin/hair). private static readonly bool DumpOpcodesEnabled = Environment.GetEnvironmentVariable("ACDREAM_DUMP_OPCODES") == "1"; - private static readonly bool DumpVitalsEnabled = - Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1"; private static readonly bool DumpAppearanceEnabled = Environment.GetEnvironmentVariable("ACDREAM_DUMP_APPEARANCE") == "1"; private readonly System.Collections.Generic.HashSet _seenUnhandledOpcodes = new(); @@ -2249,8 +2246,6 @@ public sealed class WorldSession : IDisposable // format per holtburger UpdateVital — see // PrivateUpdateVital.TryParseFull. var parsed = PrivateUpdateVital.TryParseFull(body); - if (DumpVitalsEnabled) - Console.WriteLine($"vitals: 0x02E7 PrivateUpdateVital body.len={body.Length} parsed={(parsed is null ? "null" : $"v{parsed.Value.VitalId} ranks={parsed.Value.Ranks} start={parsed.Value.Start} cur={parsed.Value.Current}")}"); if (parsed is not null) VitalUpdated?.Invoke(parsed.Value); } @@ -2259,8 +2254,6 @@ public sealed class WorldSession : IDisposable // Issue #5: current-only delta (regen ticks / drains). // Wire format per holtburger UpdateVitalCurrent. var parsed = PrivateUpdateVital.TryParseCurrent(body); - if (DumpVitalsEnabled) - Console.WriteLine($"vitals: 0x02E9 PrivateUpdateVitalCurrent body.len={body.Length} parsed={(parsed is null ? "null" : $"v{parsed.Value.VitalId} cur={parsed.Value.Current}")}"); if (parsed is not null) VitalCurrentUpdated?.Invoke(parsed.Value); } diff --git a/src/AcDream.Core/Items/VendorDiagnostics.cs b/src/AcDream.Core/Items/VendorDiagnostics.cs deleted file mode 100644 index 9e8d058e..00000000 --- a/src/AcDream.Core/Items/VendorDiagnostics.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; - -namespace AcDream.Core.Items; - -/// -/// TEMPORARY diagnostic-probe owner for two live-only regressions that have -/// each survived two green-tested fixes: Chain A (a far-click walk-to-use -/// approaches a vendor, the vendor plays its cosmetic greeting, but the shop -/// window never opens) and Chain B (selecting a splittable vendor stack -/// shows the bare item name with no quantity slider). Every probe line is -/// prefixed [vendor-diag] and gated on -/// so the family is a true no-op when the flag is unset. -/// -/// -/// Read once from ACDREAM_DUMP_VENDOR=1 at process start, per Code -/// Structure Rule 5 (one static diagnostic-owner class per subsystem, no -/// per-call-site reads). -/// Lives in AcDream.Core — the one project every call site (App, -/// Core.Net, Runtime) already references — so a single flag instance is -/// shared across the whole probe family regardless of which layer observes -/// it first. -/// -public static class VendorDiagnostics -{ - public static bool DumpVendorEnabled { get; } = - Environment.GetEnvironmentVariable("ACDREAM_DUMP_VENDOR") == "1"; -} diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index 0f3f0102..cc8a9231 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -1637,7 +1637,7 @@ public static class PhysicsDiagnostics // channel), naming which phase halted the attempt and, on Collided, // the colliding polygon's normal and which of the three channels // wrote it. - // [transit-fail-stepup] — DoStepUp entry/exit, mirroring the existing + // [transit-fail-stepup] — DoStepUp entry/exit, mirroring the retired // ACDREAM_DUMP_STEPUP probe's own input-normal-and-verdict content // so a stuck-tick capture carries the step chain without running a // second flag. @@ -1753,7 +1753,7 @@ public static class PhysicsDiagnostics /// /// One line per Transition.DoStepUp entry or exit, mirroring the - /// content of the existing ACDREAM_DUMP_STEPUP probe (same input + /// content of the retired ACDREAM_DUMP_STEPUP probe (same input /// normal / walkable verdict / landing-plane fields) so a stuck-tick /// capture carries the step-up chain without a second flag. /// is "enter" or "exit"; diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 7c60c224..c6a80164 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -1349,9 +1349,6 @@ public sealed class Transition CollisionInfo.CopyFrom(source.CollisionInfo); } - private static bool DumpEdgeSlideEnabled => - Environment.GetEnvironmentVariable("ACDREAM_DUMP_EDGE_SLIDE") == "1"; - // ----------------------------------------------------------------------- // A6.P7 (2026-05-25) — retail-binary shape dispatch rule // ----------------------------------------------------------------------- @@ -2227,7 +2224,6 @@ public sealed class Transition if (ci.ContactPlaneValid) return TransitionState.OK; - DumpStepDownBranchGate(contactInvalid: true); if (oi.Contact && !sp.StepDown && sp.CheckCellId != 0 && oi.StepDown) { // L.2.3i (2026-04-29): retail uses FloorZ when OnWalkable, @@ -2280,7 +2276,6 @@ public sealed class Transition // intentionally narrow: it tells the next L.2c slice whether // we are missing precipice context, a steep contact plane, or // merely the EdgeSlide flag. - DumpEdgeSlideStepDownFailed(stepDownHeight, zVal); bool stop = EdgeSlideAfterStepDownFailed( engine, @@ -2469,7 +2464,6 @@ public sealed class Transition TransitionCellCollisionPhase.Objects, cellId, FindObjCollisionsInCell(engine, cellId)); - DumpPhase2(innerAttempt, environment, objects); PhysicsDiagnostics.TraceTransitInsertAttempt( ObjectInfo.SelfEntityId, innerAttempt, "objects", environment, building, objects, objects, @@ -2501,7 +2495,6 @@ public sealed class Transition // No steep-plane exception precedes this gate (0050b3d8-0050b3e7). if (!oi.OnWalkable || !oi.EdgeSlide) { - DumpEdgeSlideBranch("branch1/!onwalkable-or-!edgeslide", zVal); sp.ClearWalkable(); sp.RestoreCheckPos(); ci.ContactPlaneValid = false; @@ -2513,7 +2506,6 @@ public sealed class Transition if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal) { var cliffPlane = ci.ContactPlane; - DumpEdgeSlideBranch("branch2/steep-cliffslide", zVal); sp.ClearWalkable(); sp.RestoreCheckPos(); ci.ContactPlaneValid = false; @@ -2536,7 +2528,6 @@ public sealed class Transition // rapidly down the stairs. Do not restore stale history here. if (sp.HasWalkablePolygon) { - DumpEdgeSlideBranch("branch3/precipice-slide", zVal); sp.RestoreCheckPos(); ci.ContactPlaneValid = false; ci.ContactPlaneIsWater = false; @@ -2546,7 +2537,6 @@ public sealed class Transition if (ci.ContactPlaneValid) { - DumpEdgeSlideBranch("branch4/contact-no-walkable", zVal); sp.ClearWalkable(); sp.RestoreCheckPos(); ci.ContactPlaneValid = false; @@ -2624,8 +2614,6 @@ public sealed class Transition Vector3 collideNormal = new(-contactNormal.Y, contactNormal.X, 0f); if (collideNormal.LengthSquared() < PhysicsGlobals.EpsilonSq) { - DumpCliffSlide("degenerate-cross/last-known", contactPlane, - new Plane(referenceNormal, 0f), contactNormal, 0f, false); return TransitionState.OK; } @@ -2633,8 +2621,6 @@ public sealed class Transition Vector3 offset = sp.GlobalSphere[0].Origin - sp.GlobalCurrCenter[0].Origin; float angle = Vector3.Dot(collideNormal, offset); - DumpCliffSlide("ok/last-known", contactPlane, - new Plane(referenceNormal, 0f), collideNormal, angle, true); if (angle <= 0f) { @@ -2650,89 +2636,6 @@ public sealed class Transition return TransitionState.Adjusted; } - private void DumpEdgeSlideStepDownFailed(float stepDownHeight, float zVal) - { - if (!DumpEdgeSlideEnabled) return; - - var sp = SpherePath; - var ci = CollisionInfo; - var oi = ObjectInfo; - - Console.WriteLine( - System.FormattableString.Invariant( - $"edge-slide: stepdown-failed cur={Fmt(sp.CurPos)} check={Fmt(sp.CheckPos)} cell=0x{sp.CheckCellId:X8} edgeFlag={oi.EdgeSlide} contactFlag={oi.Contact} onWalkable={oi.OnWalkable} contactPlane={ci.ContactPlaneValid} lastPlane={ci.LastKnownContactPlaneValid} walkableValid={sp.WalkableValid} walkablePoly={sp.HasWalkablePolygon} lastWalkablePoly={sp.HasLastWalkablePolygon} stepDown={stepDownHeight:F3} zVal={zVal:F3}")); - } - - /// - /// L.4-diag: log step-down branch gate decision. Whether we entered or - /// skipped the contact-recovery branch matters for whether CliffSlide - /// has any chance of firing. - /// - private void DumpStepDownBranchGate(bool contactInvalid) - { - if (!DumpEdgeSlideEnabled) return; - - var sp = SpherePath; - var ci = CollisionInfo; - var oi = ObjectInfo; - - bool wouldEnter = contactInvalid && oi.Contact && !sp.StepDown - && sp.CheckCellId != 0 && oi.StepDown; - - if (!wouldEnter) return; // only log when entering, to keep noise low - - Console.WriteLine( - System.FormattableString.Invariant( - $"edge-slide: stepdown-branch-enter cur={Fmt(sp.CurPos)} contactValid={ci.ContactPlaneValid} contactN.Z={(ci.ContactPlaneValid ? ci.ContactPlane.Normal.Z : 0f):F3} onWalk={oi.OnWalkable} contact={oi.Contact}")); - } - - /// - /// L.4-diag: log Phase 2 outcome per inner attempt. Tells us whether - /// we're churning in Slid retries or escaping to step-down branch. - /// - private void DumpPhase2(int attempt, TransitionState envState, TransitionState objState) - { - if (!DumpEdgeSlideEnabled) return; - if (objState == TransitionState.OK) return; // skip clean attempts - - Console.WriteLine( - System.FormattableString.Invariant( - $"edge-slide: phase2 attempt={attempt} env={envState} obj={objState}")); - } - - /// - /// L.4-diag: log which branch of EdgeSlideAfterStepDownFailed fired. - /// Tells us whether CliffSlide gets called or whether we hit a - /// stop-at-edge branch. - /// - private void DumpEdgeSlideBranch(string branch, float zVal) - { - if (!DumpEdgeSlideEnabled) return; - var sp = SpherePath; - var ci = CollisionInfo; - var oi = ObjectInfo; - Console.WriteLine( - System.FormattableString.Invariant( - $"edge-slide: branch={branch} contactValid={ci.ContactPlaneValid} contactN.Z={(ci.ContactPlaneValid ? ci.ContactPlane.Normal.Z : 0f):F3} lastValid={ci.LastKnownContactPlaneValid} lastN.Z={(ci.LastKnownContactPlaneValid ? ci.LastKnownContactPlane.Normal.Z : 0f):F3} walkPolyValid={sp.HasWalkablePolygon} walkPolyN.Z={(sp.HasWalkablePolygon ? sp.WalkablePlane.Normal.Z : 0f):F3} lastWalkPolyN.Z={(sp.HasLastWalkablePolygon ? sp.LastWalkablePlane.Normal.Z : 0f):F3} onWalk={oi.OnWalkable} edgeFlag={oi.EdgeSlide} zVal={zVal:F3}")); - } - - /// - /// L.4-diag: log CliffSlide invocation. Tells us whether the - /// cross-product is degenerate (no slide) or producing a real - /// deflection. - /// - private void DumpCliffSlide(string outcome, Plane current, Plane lastKnown, - Vector3 collideNormal, float angle, bool willApply) - { - if (!DumpEdgeSlideEnabled) return; - Console.WriteLine( - System.FormattableString.Invariant( - $"edge-slide: cliffslide outcome={outcome} curN={Fmt(current.Normal)} lastN={Fmt(lastKnown.Normal)} collideN={Fmt(collideNormal)} angle={angle:F4} apply={willApply}")); - } - - private static string Fmt(Vector3 value) => - System.FormattableString.Invariant($"({value.X:F3},{value.Y:F3},{value.Z:F3})"); - // ----------------------------------------------------------------------- // Environment collision — outdoor terrain // ----------------------------------------------------------------------- @@ -5739,29 +5642,9 @@ public sealed class Transition var ci = CollisionInfo; var oi = ObjectInfo; - // L.2.3f (2026-04-29): diagnostic for steep-roof bug. Logs the - // input polygon normal that triggered step-up. The verdict tells - // whether THIS polygon would pass FloorZ (≈ 0.66) — but actual - // step-up acceptance depends on the polygon found by step_sphere_down - // INSIDE the recursive TransitionalInsert, which may be different. - // The post-step "result=" line below logs that outcome. - bool diag = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEPUP") == "1"; - if (diag) - { - float floor = PhysicsGlobals.FloorZ; - string verdict = collisionNormal.Z >= floor ? "WALKABLE" : "STEEP"; - Console.WriteLine( - $"stepup: enter normal=({collisionNormal.X:F3},{collisionNormal.Y:F3},{collisionNormal.Z:F3}) " + - $"|Z|={collisionNormal.Z:F3} vs FloorZ={floor:F3} → {verdict}, " + - $"OnWalkable={(oi.State & ObjectInfoState.OnWalkable) != 0}, " + - $"StepUpHeight={oi.StepUpHeight:F3}, " + - $"CurPos=({sp.CurPos.X:F2},{sp.CurPos.Y:F2},{sp.CurPos.Z:F2})"); - } - - // #345 probe (2026-08-08): mirrors the ACDREAM_DUMP_STEPUP content - // above into the buffered stuck-tick trace (self-guards internally, - // zero cost when ACDREAM_DUMP_TRANSIT_FAIL is unset) so a stuck-tick - // capture carries the step-up chain without a second flag. + // #345 probe (2026-08-08): buffers this step-up attempt into the + // stuck-tick trace (self-guards internally, zero cost when + // ACDREAM_DUMP_TRANSIT_FAIL is unset). PhysicsDiagnostics.TraceTransitStepUp( oi.SelfEntityId, "enter", collisionNormal, onWalkable: (oi.State & ObjectInfoState.OnWalkable) != 0, @@ -5807,28 +5690,6 @@ public sealed class Transition sp.StepUp = false; sp.ClearWalkable(); - // L.2.3f: log the result + landing plane if step-up succeeded. - // This is the actual surface the player ended up on, which may - // differ from the input collision normal (e.g. step-up scanned - // past a steep slope and landed on a flatter polygon higher up). - if (diag) - { - if (stepDown && ci.ContactPlaneValid) - { - float floor = PhysicsGlobals.FloorZ; - string verdict = ci.ContactPlane.Normal.Z >= floor ? "WALKABLE" : "STEEP"; - Console.WriteLine( - $"stepup: SUCCESS — landed on plane normal=" + - $"({ci.ContactPlane.Normal.X:F3},{ci.ContactPlane.Normal.Y:F3},{ci.ContactPlane.Normal.Z:F3}) " + - $"|Z|={ci.ContactPlane.Normal.Z:F3} vs FloorZ={floor:F3} → {verdict}, " + - $"new CheckPos=({sp.CheckPos.X:F2},{sp.CheckPos.Y:F2},{sp.CheckPos.Z:F2})"); - } - else - { - Console.WriteLine($"stepup: FAILED — sliding back along normal"); - } - } - // #345 probe (2026-08-08): the matching exit line for the "enter" // trace above. PhysicsDiagnostics.TraceTransitStepUp( diff --git a/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs b/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs index 503e3c33..37b11903 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs @@ -224,11 +224,6 @@ public sealed class RuntimeInteractionTransactionState : IDisposable verdict = RuntimeInteractionDispatchResult.Dispatched; } - if (VendorDiagnostics.DumpVendorEnabled) - { - Console.WriteLine( - $"[vendor-diag] TryDispatchUse guid=0x{serverGuid:X8} ownedByPlayer={ownedByPlayer} useable={useable} verdict={verdict} seq={sequence}"); - } return verdict; } @@ -633,11 +628,6 @@ public sealed class RuntimeInteractionTransactionState : IDisposable if (_pendingUse is not { } current || current.ApproachToken != approachToken) { - if (VendorDiagnostics.DumpVendorEnabled) - { - Console.WriteLine( - $"[vendor-diag] TryResolveUseApproachCompletion no-match approachToken=({approachToken.ControllerLifetime},{approachToken.ApproachGeneration}) natural={natural} hasPendingUse={_pendingUse is not null}"); - } pending = default; return false; } @@ -645,11 +635,6 @@ public sealed class RuntimeInteractionTransactionState : IDisposable _pendingUse = null; pending = current; IncrementRevision(); - if (VendorDiagnostics.DumpVendorEnabled) - { - Console.WriteLine( - $"[vendor-diag] TryResolveUseApproachCompletion guid=0x{pending.ServerGuid:X8} natural={natural} accepted={natural}"); - } return natural; } diff --git a/src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs b/src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs index 82568a44..1e62454b 100644 --- a/src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs +++ b/src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs @@ -191,7 +191,6 @@ public sealed class VendorShopItemMaterializer : IDisposable } } - int diagIndex = 0; foreach (VendorShopItem item in currentItems) { bool ownedAlready = _ownedGuids.ContainsKey(item.ItemGuid); @@ -208,16 +207,6 @@ public sealed class VendorShopItemMaterializer : IDisposable } WeenieData materialized = ToWeenieData(item, transition.VendorId); - if (VendorDiagnostics.DumpVendorEnabled && diagIndex < 5) - { - Console.WriteLine( - $"[vendor-diag] materialize[{diagIndex}] guid=0x{item.ItemGuid:X8} " - + $"descStackSize={(item.DescStackSize is { } ds ? ds.ToString() : "null")} " - + $"maxStackSize={(item.MaxStackSize is { } ms ? ms.ToString() : "null")} " - + $"resolvedStackSize={(materialized.StackSize is { } rs ? rs.ToString() : "null")} " - + $"resolvedStackSizeMax={(materialized.StackSizeMax is { } rm ? rm.ToString() : "null")}"); - diagIndex++; - } _objects.Ingest(materialized); nextOwned[item.ItemGuid] = transition.VendorId; diff --git a/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs b/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs index 6297df33..6652db65 100644 --- a/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/LaunchOptionsDocumentationTests.cs @@ -47,17 +47,13 @@ public sealed class LaunchOptionsDocumentationTests ["src/AcDream.App/Physics/RemoteServerControlledVelocityCycle.cs"] = 1, ["src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs"] = 1, ["src/AcDream.App/Rendering/GameWindow.cs"] = 1, - ["src/AcDream.App/Rendering/PortalVisibilityBuilder.cs"] = 1, ["src/AcDream.App/Rendering/Sky/SkyRenderer.cs"] = 1, ["src/AcDream.App/Rendering/TextureCache.cs"] = 1, ["src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs"] = 2, - ["src/AcDream.Core/Physics/TransitionTypes.cs"] = 2, ["src/AcDream.Core/Vfx/PhysicsScriptRunner.cs"] = 1, ["src/AcDream.Core/World/SkyDescLoader.cs"] = 2, - ["src/AcDream.Core.Net/GameEventWiring.cs"] = 1, - ["src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs"] = 1, ["src/AcDream.Core.Net/Messages/UpdateMotion.cs"] = 1, - ["src/AcDream.Core.Net/WorldSession.cs"] = 3, + ["src/AcDream.Core.Net/WorldSession.cs"] = 2, ["src/AcDream.Platform/ApplicationPathSet.cs"] = 3, ["src/AcDream.Platform/BakePublicationGuardPaths.cs"] = 1, ["src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs"] = 1, @@ -110,6 +106,65 @@ public sealed class LaunchOptionsDocumentationTests + "removal commit) in the same commit that deletes the read site."); } + /// + /// The four flags that default ON. All are retail behaviors wearing an + /// A/B off-switch (=0 disables) — none is a diagnostic. FROZEN: + /// a diagnostic that activates without its env var set taxes every run + /// and every measurement silently, so growing this set fails. + /// + private static readonly IReadOnlySet DefaultOnBehaviorFlags = + new HashSet(StringComparer.Ordinal) + { + "ACDREAM_RETAIL_CHASE", + "ACDREAM_CAMERA_COLLIDE", + "ACDREAM_CAMERA_ALIGN_SLOPE", + "ACDREAM_RETAIL_CLOSE_DEGRADES", + }; + + /// + /// A read shaped "anything but the literal 0 enables" — the two + /// default-on idioms in this codebase: + /// GetEnvironmentVariable("X") != "0" and + /// !string.Equals(env("X"), "0", ...). + /// + private static readonly Regex DefaultOnRead = new( + @"(?:GetEnvironmentVariable\(\s*""(ACDREAM_[A-Z0-9_]+)""\s*\)\s*!=\s*""0""" + + @"|!string\.Equals\(\s*env\(\s*""(ACDREAM_[A-Z0-9_]+)""\s*\)\s*,\s*""0"")", + RegexOptions.Compiled); + + [Fact] + public void OnlyTheFourRetailBehaviorFlagsDefaultOn() + { + var defaultOn = new HashSet(StringComparer.Ordinal); + foreach ((string path, _) in SourceFiles()) + { + foreach (Match match in DefaultOnRead.Matches(File.ReadAllText(path))) + { + defaultOn.Add(match.Groups[1].Success + ? match.Groups[1].Value + : match.Groups[2].Value); + } + } + + List added = defaultOn.Except(DefaultOnBehaviorFlags) + .Order(StringComparer.Ordinal).ToList(); + Assert.True( + added.Count == 0, + "New default-ON flag(s): " + string.Join(", ", added) + + ". Every diagnostic must be OFF until its variable is " + + "explicitly set; only a retail behavior with an A/B off-switch " + + "may default on, and adding one means updating this frozen set " + + "AND the Conventions section of docs/launch-options.md."); + + List gone = DefaultOnBehaviorFlags.Except(defaultOn) + .Order(StringComparer.Ordinal).ToList(); + Assert.True( + gone.Count == 0, + "Frozen default-ON flag(s) no longer read that way: " + + string.Join(", ", gone) + + ". Update this set and the docs in the same commit."); + } + [Fact] public void DirectEnvironmentReadsOutsideOwnerClassesDoNotGrow() { diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs index 3f5739db..10902a19 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs @@ -160,7 +160,6 @@ public sealed class RuntimeOptionsTests // Default-on: RetailCloseDegrades is true unless explicitly disabled. Assert.True(opts.RetailCloseDegrades); Assert.False(opts.DumpSceneryZ); - Assert.False(opts.DumpClothing); Assert.Null(opts.LegacyStreamRadius); Assert.False(opts.UiProbeDump); Assert.Null(opts.UiProbeScript); @@ -508,7 +507,6 @@ public sealed class RuntimeOptionsTests ["ACDREAM_DUMP_SKY"] = "1", ["ACDREAM_NO_AUDIO"] = "1", ["ACDREAM_DUMP_SCENERY_Z"] = "1", - ["ACDREAM_DUMP_CLOTHING"] = "1", })); Assert.True(allOn.DevTools); Assert.True(allOn.UncappedRendering); @@ -516,25 +514,22 @@ public sealed class RuntimeOptionsTests Assert.True(allOn.DumpSky); Assert.True(allOn.NoAudio); Assert.True(allOn.DumpSceneryZ); - Assert.True(allOn.DumpClothing); // Any non-"1" value leaves them off, matching the // string.Equals(env, "1", StringComparison.Ordinal) check. var anyOther = RuntimeOptions.Parse(AnyDatDir, Env(new() { + ["ACDREAM_DUMP_MOVE_TRUTH"] = "true", ["ACDREAM_DEVTOOLS"] = "true", ["ACDREAM_UNCAPPED_RENDER"] = "true", - ["ACDREAM_DUMP_MOVE_TRUTH"] = "yes", ["ACDREAM_NO_AUDIO"] = "2", ["ACDREAM_DUMP_SCENERY_Z"] = " 1", - ["ACDREAM_DUMP_CLOTHING"] = "true", })); Assert.False(anyOther.DevTools); Assert.False(anyOther.UncappedRendering); Assert.False(anyOther.DumpMoveTruth); Assert.False(anyOther.NoAudio); Assert.False(anyOther.DumpSceneryZ); - Assert.False(anyOther.DumpClothing); } [Fact] diff --git a/tests/AcDream.Core.Tests/Physics/CellarLipWedgeTests.cs b/tests/AcDream.Core.Tests/Physics/CellarLipWedgeTests.cs index 3d69a53c..b80fbffd 100644 --- a/tests/AcDream.Core.Tests/Physics/CellarLipWedgeTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellarLipWedgeTests.cs @@ -320,7 +320,6 @@ public class CellarLipWedgeTests var sw = new StringWriter(); PhysicsDiagnostics.ProbeIndoorBspEnabled = true; PhysicsDiagnostics.ProbeStepWalkEnabled = true; - Environment.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", "1"); Console.SetOut(sw); try { @@ -334,7 +333,6 @@ public class CellarLipWedgeTests finally { Console.SetOut(saved); - Environment.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", null); PhysicsDiagnostics.ProbeIndoorBspEnabled = false; PhysicsDiagnostics.ProbeStepWalkEnabled = false; } diff --git a/tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs b/tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs index e1c524b6..db4e83d9 100644 --- a/tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs +++ b/tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs @@ -267,7 +267,6 @@ public class DoorCollisionApparatusTests PhysicsDiagnostics.ProbeResolveEnabled = true; PhysicsDiagnostics.ProbeBuildingEnabled = true; - Env.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", "1"); // Synthetic floor plane at Z = 0 so the grounded sphere has a // walkable plane to rest on. Sphere foot center starts at Z=radius @@ -339,7 +338,6 @@ public class DoorCollisionApparatusTests // (Y < 12.0) — the same shape as the Path-6 apparatus tests above. PhysicsDiagnostics.ProbeResolveEnabled = false; PhysicsDiagnostics.ProbeBuildingEnabled = false; - Env.SetEnvironmentVariable("ACDREAM_DUMP_STEPUP", null); Assert.True(blocked, $"Door must block the grounded off-center approach (#99 closed). " + From 7969a20c8c40a2597f5c45019a3e9f2a8f0a7e0f Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 12:33:46 +0200 Subject: [PATCH 11/89] =?UTF-8?q?docs:=20correct=20the=20probe-debt=20call?= =?UTF-8?q?out=20=E2=80=94=2031=20temporary=20probes=20remain,=20all=20att?= =?UTF-8?q?ributed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The part-2 commit message and the callout said 40; the 8 reclassified rows had left the Temporary table, so the true count is 31 (and the previous commit's '40 temporary probes remain' line is corrected by this note). Co-Authored-By: Claude Fable 5 --- docs/launch-options.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/launch-options.md b/docs/launch-options.md index 37487fa0..5c1d0e3c 100644 --- a/docs/launch-options.md +++ b/docs/launch-options.md @@ -287,15 +287,16 @@ issue is closed, the strip was missed; delete both. > (`ACDREAM_A8_DUMP_PV`/Phase A8, `ACDREAM_DUMP_CLOTHING`/#37, > `ACDREAM_DUMP_EDGE_SLIDE`/#32, `ACDREAM_DUMP_LIVE_SPAWNS`/Phase A8, > `ACDREAM_DUMP_STEPUP`/L.2.3d-f, `ACDREAM_DUMP_VENDOR`/the vendor -> campaign, `ACDREAM_DUMP_VITALS`/#5), leaving 40. An eighth, +> campaign, `ACDREAM_DUMP_VITALS`/#5). An eighth, > `ACDREAM_DUMP_MOVE_TRUTH`, was deleted and then RESTORED the same day: > it turned out to be automation apparatus, not a probe — the canonical > nine-stop soak hard-gates on its output (see its row under Automation; -> #437 is the record). The remaining rows attributed at part 2 were -> reclassified into Permanent diagnostics as standing tools rather than -> investigation probes. Every probe here still costs a branch on its hot -> path even when unset, and a handful re-read the environment per frame -> rather than caching (see their side-effects column). +> #437 is the record). The rest of the attributed rows were reclassified +> into Permanent diagnostics as standing tools rather than investigation +> probes, leaving **31 temporary probes, every one attributed to an owning +> issue or campaign**. Each still costs a branch on its hot path even when +> unset, and a handful re-read the environment per frame rather than +> caching (see their side-effects column). | Flag | Owning investigation | Value | What it does | Side effects | Read by | |---|---|---|---|---|---| From 35454a9f5843e690a8a315532f77c1634b8020d3 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 12:53:11 +0200 Subject: [PATCH 12/89] fix #436: combat no-target refusal reaches the SpewBox with retail's exact text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attacking with no valid target has told the player nothing since Campaign V slice V11 orphaned the DebugVM toast the message was wired to (#434 found the drop; this closes it retail-faithfully). Ground truth from the Ghidra decompile of ClientCombatSystem::ExecuteAttack (0x0056bb70): retail writes "You must select a valid combat target before attacking" via ClientSystem::AddTextToScroll(..., 0x1A, true, 0) — the ClientLocal SpewBox channel this codebase already routes every other client-local refusal through. And retail has ONE message, not the two we carried: attacking outside melee/missile modes is silent (ExecuteAttack is unreachable there), so the invented "Enter melee or missile combat first" text is deleted rather than rerouted, and the invented "No monster target" is replaced by the retail string, which joins ClientTextRefusals with its decomp citation. Wiring: CombatFeedbackSlot gains the sibling BindOwned session-lifetime shape, and SessionPlayerComposition.CompleteSessionPlayer binds it to RuntimeCommunicationState.AddText(ClientLocal) with session-owned teardown — a torn-down session's slot returns to its silent unbound state. A binding-seam test (CompleteSessionPlayerBindsCombatFeedbackToTheClientLocalSpewBoxRoute) inspects the compiled composition for the BindOwned call and its AddText-routing lambda, so the slot can never again pass its unit tests while production leaves it unbound — the exact failure mode that hid this defect. The two tests that pinned the invented strings now pin the retail contract (exact string; silence for the unsupported-mode case). Full hermetic suite 15,325 passed / 0 failed. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 24 ++++++++++- .../Combat/LiveCombatAttackOperations.cs | 43 ++++++++++++++----- .../Composition/SessionPlayerComposition.cs | 10 +++++ src/AcDream.Core/Chat/ClientTextRefusals.cs | 15 +++++++ .../Combat/CombatFeedbackSlotTests.cs | 37 ++++++++++++++-- .../Combat/LiveCombatAttackOperationsTests.cs | 15 +++++-- .../SessionPlayerCompositionTests.cs | 32 ++++++++++++++ 7 files changed, 157 insertions(+), 19 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 915cada9..aefc049b 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -138,9 +138,29 @@ id list), so deletion order matters. --- -## #436 — Combat refusal text ("No monster target") is silently dropped +## #436 — CLOSED: Combat refusal text ("No monster target") is silently dropped -**Status:** OPEN +**Status:** CLOSED 2026-08-24, retail-faithfully. Ghidra decompile of +`ClientCombatSystem::ExecuteAttack` (0x0056bb70) settled both open +questions: (1) retail's exact string is **"You must select a valid combat +target before attacking"**, routed via +`ClientSystem::AddTextToScroll(..., 0x1A, true, 0)` — the ClientLocal +SpewBox channel our chat pipeline already owns; (2) retail has ONE message, +not two — attacking while not in melee/missile mode is silent (that path is +unreachable in retail's dispatch), so the invented "Enter melee or missile +combat first" text is deleted rather than rerouted. Implementation: the +string joined `ClientTextRefusals` with its decomp citation; +`CombatFeedbackSlot` gained the sibling `BindOwned` session-lifetime shape; +`SessionPlayerComposition.CompleteSessionPlayer` binds it to +`RuntimeCommunicationState.AddText(ClientLocal)` with session-owned +teardown. Coverage includes a binding-seam test +(`CompleteSessionPlayerBindsCombatFeedbackToTheClientLocalSpewBoxRoute`) +so the slot can never again pass its unit tests while production leaves it +unbound — the exact failure mode that hid this for months. + +**Original report follows.** + +**Status (original):** OPEN **Severity:** MEDIUM (missing user feedback on a common action) **Filed:** 2026-08-24 (exposed by #434's dead-code removal) **Component:** combat / chat presentation diff --git a/src/AcDream.App/Combat/LiveCombatAttackOperations.cs b/src/AcDream.App/Combat/LiveCombatAttackOperations.cs index 96816196..79fff29c 100644 --- a/src/AcDream.App/Combat/LiveCombatAttackOperations.cs +++ b/src/AcDream.App/Combat/LiveCombatAttackOperations.cs @@ -67,16 +67,18 @@ internal interface ICombatFeedbackSink } /// -/// Routes combat refusal text ("No monster target") to whichever surface is -/// bound to show it. +/// Routes combat refusal text to whichever surface is bound to show it — +/// in production, the SpewBox as RetailLogTextType.ClientLocal +/// (retail: ClientCombatSystem::ExecuteAttack 0x0056bb70 → +/// ClientSystem::AddTextToScroll(..., 0x1A, ...)). /// /// -/// #434: the bound target used to be the developer DebugVM, which -/// Campaign V slice V11 left unreachable — nothing has constructed it since, -/// so both messages below have been going nowhere. The binding target is now a -/// plain delegate so this seam no longer depends on that dead class, but it -/// still has no production binder: wiring it to the chat window, where retail -/// puts this text, is #436. +/// #434/#436: the bound target used to be the developer DebugVM, +/// which Campaign V slice V11 left unreachable, so these messages were +/// silently dropped. The session composition now owns the binding via +/// (same lifetime shape as +/// ); before a session binds — +/// and after it tears down — is a deliberate no-op. /// internal sealed class CombatFeedbackSlot : ICombatFeedbackSink { @@ -91,6 +93,16 @@ internal sealed class CombatFeedbackSlot : ICombatFeedbackSink _target = target; } + public IDisposable BindOwned(Action target) + { + ArgumentNullException.ThrowIfNull(target); + if (_target is not null) + throw new InvalidOperationException( + "Combat feedback is already bound to a presentation target."); + _target = target; + return new Binding(this, target); + } + public void Unbind(Action target) { ArgumentNullException.ThrowIfNull(target); @@ -99,6 +111,12 @@ internal sealed class CombatFeedbackSlot : ICombatFeedbackSink } public void Show(string message) => _target?.Invoke(message); + + private sealed class Binding(CombatFeedbackSlot slot, Action target) + : IDisposable + { + public void Dispose() => slot.Unbind(target); + } } internal sealed class CombatAttackOperationsSlot @@ -221,7 +239,10 @@ internal sealed class LiveCombatAttackOperations if (!CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode)) { - _feedback.Show("Enter melee or missile combat first"); + // Retail is SILENT here: ClientCombatSystem::ExecuteAttack + // (0x0056bb70) is unreachable outside melee/missile modes, so + // no user-facing text exists for this case — only the no-target + // branch below speaks (#436). Console.WriteLine( "combat: attack ignored; not in melee/missile combat mode"); return false; @@ -229,7 +250,9 @@ internal sealed class LiveCombatAttackOperations if (_targets.GetSelectedOrClosestCombatTarget(_settings.AutoTarget) is null) { - _feedback.Show("No monster target"); + // Retail: ExecuteAttack's edi==0 branch (0x0056bc05) → + // AddTextToScroll(0x1A) — the ClientLocal SpewBox channel. + _feedback.Show(AcDream.Core.Chat.ClientTextRefusals.MustSelectCombatTarget); Console.WriteLine("combat: attack ignored; no creature target found"); return false; } diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index bbb7bb9d..14fbe652 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -809,6 +809,16 @@ internal sealed class SessionPlayerCompositionPhase liveSessionSource, liveSessionSource, d.CombatFeedback))); + // #436: combat refusal text goes to the SpewBox as ClientLocal — + // retail's ClientCombatSystem::ExecuteAttack (0x0056bb70) routes + // "You must select a valid combat target before attacking" through + // AddTextToScroll(0x1A). Session-owned so a torn-down session's + // slot goes back to its silent unbound state. + bindings.Adopt( + "combat feedback", + d.CombatFeedback.BindOwned( + text => d.Communication.AddText( + text, RetailLogTextType.ClientLocal))); Fault(SessionPlayerCompositionPoint.CombatOperationsBound); MouseLookController? mouseLook = diff --git a/src/AcDream.Core/Chat/ClientTextRefusals.cs b/src/AcDream.Core/Chat/ClientTextRefusals.cs index a1851827..d7a65c8d 100644 --- a/src/AcDream.Core/Chat/ClientTextRefusals.cs +++ b/src/AcDream.Core/Chat/ClientTextRefusals.cs @@ -144,4 +144,19 @@ public static class ClientTextRefusals /// SpewBox-only channel every other refusal in this file uses. /// public const string CantLogOffMidAir = "Cannot log off while in mid-air."; + + /// + /// #436 — attack pressed with no valid combat target. Inline literal in + /// ClientCombatSystem::ExecuteAttack (0x0056bb70; the + /// refusal branch at 0x0056bc05), routed via + /// ClientSystem::AddTextToScroll(..., 0x1A, true, 0) — the same + /// RetailLogTextType.ClientLocal SpewBox channel as the rest of + /// this file, though unlike the 11 globals above it is not one of + /// ClientCommunicationSystem's static-ctor strings. Retail shows + /// ONLY this message here: attacking while not in melee/missile mode is + /// silent (that path is unreachable in retail's dispatch), so acdream + /// deliberately emits nothing for that case either. + /// + public const string MustSelectCombatTarget = + "You must select a valid combat target before attacking"; } diff --git a/tests/AcDream.App.Tests/Combat/CombatFeedbackSlotTests.cs b/tests/AcDream.App.Tests/Combat/CombatFeedbackSlotTests.cs index 2f2e6d9e..ec2627e0 100644 --- a/tests/AcDream.App.Tests/Combat/CombatFeedbackSlotTests.cs +++ b/tests/AcDream.App.Tests/Combat/CombatFeedbackSlotTests.cs @@ -34,13 +34,42 @@ public sealed class CombatFeedbackSlotTests Assert.Throws(() => slot.Bind(_ => { })); } + [Fact] + public void SessionOwnedBindingForwardsAndItsDisposalRestoresSilence() + { + // #436: the session composition owns the production binding + // (SpewBox/ClientLocal) through BindOwned, mirroring + // CombatAttackOperationsSlot's lifetime shape — disposal at session + // teardown returns the slot to its silent unbound state instead of + // leaving a dead session's chat sink reachable. + var slot = new CombatFeedbackSlot(); + List sink = []; + + IDisposable binding = slot.BindOwned(sink.Add); + slot.Show("routed"); + Assert.Equal(["routed"], sink); + + binding.Dispose(); + slot.Show("after-teardown"); + Assert.Equal(["routed"], sink); + } + + [Fact] + public void SessionOwnedBindingRefusesASecondOwner() + { + var slot = new CombatFeedbackSlot(); + using IDisposable binding = slot.BindOwned(_ => { }); + + Assert.Throws(() => slot.BindOwned(_ => { })); + } + [Fact] public void AnUnboundSlotDropsItsMessages() { - // #434/#436: this is the shipped behavior, not an aspiration — - // nothing binds the slot in production, so combat refusal text - // ("No monster target") is discarded. Pinned so the day it gets a - // real binder, this test is the one that has to change. + // Deliberate: before a session composes (and after one tears down) + // there is no chat surface, so Show is a no-op rather than a queue — + // matching retail, where the refusal text only exists inside a live + // session's ExecuteAttack path (0x0056bb70). var slot = new CombatFeedbackSlot(); slot.Show("dropped"); diff --git a/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs b/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs index 12ca50ae..72f2e963 100644 --- a/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs +++ b/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs @@ -54,13 +54,18 @@ public sealed class LiveCombatAttackOperationsTests } [Fact] - public void UnsupportedCombatModeUsesTypedFeedbackSink() + public void UnsupportedCombatModeRefusesSilently() { + // #436: retail emits NO text for this case — + // ClientCombatSystem::ExecuteAttack (0x0056bb70) is unreachable + // outside melee/missile modes, so there is no retail string to show. + // The pre-#436 "Enter melee or missile combat first" message was + // invented dev text and is deleted, not rerouted. Harness harness = CreateHarness(inWorld: true); Assert.False(harness.Owner.CanStartAttack()); - Assert.Equal(["Enter melee or missile combat first"], harness.Feedback.Messages); + Assert.Empty(harness.Feedback.Messages); Assert.Equal(0, harness.Targets.ResolveCount); } @@ -88,7 +93,11 @@ public sealed class LiveCombatAttackOperationsTests Assert.False(harness.Owner.CanStartAttack()); - Assert.Equal(["No monster target"], harness.Feedback.Messages); + // Retail's exact string — ClientCombatSystem::ExecuteAttack's + // no-valid-target branch (0x0056bc05), AddTextToScroll(0x1A). + Assert.Equal( + [AcDream.Core.Chat.ClientTextRefusals.MustSelectCombatTarget], + harness.Feedback.Messages); } private static Harness CreateHarness(bool inWorld = false) diff --git a/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs b/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs index 2167540c..288a21d7 100644 --- a/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/SessionPlayerCompositionTests.cs @@ -106,6 +106,38 @@ public sealed class SessionPlayerCompositionTests field => field.FieldType == typeof(GameWindow)); } + [Fact] + public void CompleteSessionPlayerBindsCombatFeedbackToTheClientLocalSpewBoxRoute() + { + // #436 binding-seam check (a slot can pass its own unit tests while + // nothing in production binds it — that is exactly how the combat + // refusals were silently dropped for months): CompleteSessionPlayer + // must session-own a CombatFeedbackSlot.BindOwned whose target lambda + // routes to RuntimeCommunicationState.AddText (the ClientLocal + // SpewBox chokepoint; retail ClientCombatSystem::ExecuteAttack + // 0x0056bb70 -> AddTextToScroll(0x1A)). + MethodInfo complete = RequiredMethod( + typeof(SessionPlayerCompositionPhase), + "CompleteSessionPlayer"); + Assert.Contains( + CompiledCallGraph.Read(complete), + call => call.Target.DeclaringType + == typeof(AcDream.App.Combat.CombatFeedbackSlot) + && call.Target.Name + == nameof(AcDream.App.Combat.CombatFeedbackSlot.BindOwned)); + + IEnumerable lambdas = + CompiledCallGraph.ReadMethodReferences(complete) + .Select(call => call.Target) + .Where(method => method.GetMethodBody() is not null); + Assert.Contains( + lambdas, + method => CompiledCallGraph.Read(method).Any(call => + call.Target.DeclaringType + == typeof(AcDream.Runtime.Gameplay.RuntimeCommunicationState) + && call.Target.Name == "AddText")); + } + [Fact] public void ProductionPhaseStartsStreamerBeforeSessionAndTransfersPortalLast() { From 1360b7168452b85a4366dbb4c91c15894c828c61 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 13:01:33 +0200 Subject: [PATCH 13/89] =?UTF-8?q?test:=20tie=20the=20soak's=20move-truth?= =?UTF-8?q?=20grep=20to=20a=20live=20emitter=20=E2=80=94=20both=20ends=20n?= =?UTF-8?q?ow=20break=20together?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The r6 soak hard-fails without 'move-truth OUT' lines, yet the only automated guard was a text assertion that the SCRIPT sets the env var — it stayed green while #435 part 2 deleted the emitter, and the breakage would have surfaced as a misleading connected-gate failure. The new contract test asserts all four links of the chain in one place: the script greps the pattern, MovementTruthDiagnosticController still emits it, RuntimeOptions still parses the flag, and GameWindow still wires it through. Deleting any link fails here, at build time, with the reason. Co-Authored-By: Claude Fable 5 --- .../ConnectedWorldSoakRouteContractTests.cs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/AcDream.App.Tests/Diagnostics/ConnectedWorldSoakRouteContractTests.cs b/tests/AcDream.App.Tests/Diagnostics/ConnectedWorldSoakRouteContractTests.cs index 76282efb..b6faeaf0 100644 --- a/tests/AcDream.App.Tests/Diagnostics/ConnectedWorldSoakRouteContractTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/ConnectedWorldSoakRouteContractTests.cs @@ -441,6 +441,43 @@ public sealed class ConnectedWorldSoakRouteContractTests Assert.Contains("$env:ACDREAM_DUMP_MOVE_TRUTH = '1'", source, StringComparison.Ordinal); } + [Fact] + public void MoveTruthGrepPatternIsBackedByALiveEmitter() + { + // #435/#437: the soak HARD-FAILS every destination when fewer than + // two fresh 'move-truth OUT' lines appear, and this env-var text + // assertion alone stayed green while the emitter was deleted — the + // script kept setting a flag nothing read. Tie BOTH ends together: + // the script must grep the pattern, and the client must still + // contain a reachable emitter producing it, gated on the option the + // script sets. Deleting either side now fails here instead of + // failing the next connected soak with a misleading message. + string script = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "tools", + "run-connected-r6-soak.ps1")); + Assert.Contains("'move-truth OUT'", script, StringComparison.Ordinal); + + string emitter = File.ReadAllText(Path.Combine( + FindRepoRoot(), + "src", "AcDream.App", "Input", + "MovementTruthDiagnosticController.cs")); + Assert.Contains("move-truth OUT", emitter, StringComparison.Ordinal); + + string options = File.ReadAllText(Path.Combine( + FindRepoRoot(), "src", "AcDream.App", "RuntimeOptions.cs")); + Assert.Contains( + @"env(""ACDREAM_DUMP_MOVE_TRUTH"")", + options, + StringComparison.Ordinal); + string window = File.ReadAllText(Path.Combine( + FindRepoRoot(), "src", "AcDream.App", "Rendering", "GameWindow.cs")); + Assert.Contains( + "options.DumpMoveTruth", + window, + StringComparison.Ordinal); + } + [Fact] public void BaselineIdentityComesFromTheMeasuredBinaryRatherThanOnlyTheCheckout() { From 373d003f1b8b7bb20e17dbd2b4cc9ce42a90cd5f Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 13:06:17 +0200 Subject: [PATCH 14/89] =?UTF-8?q?docs:=20file=20#438=20=E2=80=94=20launche?= =?UTF-8?q?r=20crash-report=20bundles=20(upcoming=20work);=20flip=20#435's?= =?UTF-8?q?=20stale=20PARTLY=20CLOSED=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #438 records the design agreed with the owner: launcher-owned opt-in WER LocalDumps key (HKCU, minidump, capped count), crash bundle assembled on the next launch from the dump + log tail + version + capability report, and an explicit NO-auto-upload line — dumps can hold the plaintext session password, so sharing stays a user action until there is real infrastructure and a consent flow. The owner's own machine is already armed manually for the #422 hunt; this productizes it for alpha users. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 58 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index aefc049b..7422b455 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,9 +24,63 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. -## #435 — PARTLY CLOSED: Probe debt: 25 temporary probes outlived their closed investigations, 6 more name no owner +## #438 — Launcher crash-report bundles (WER dump capture + local bundle, no upload) -**Status:** The 17 orphaned probes from part 1 are DELETED (2026-08-24) — +**Status:** OPEN — designed, ready to pick up as a launcher slice +**Severity:** ENHANCEMENT (post-current-queue; owner-approved as upcoming +work 2026-08-24) +**Component:** launcher / crash diagnostics + +**Problem:** the launcher already records a truthful +`exited{reason:"crashed", code}` with bounded stderr (#405–#407), but for +native fail-fasts — #422's `0xC0000374` heap corruption, three sightings, +zero stacks — no evidence exists anywhere: fail-fast bypasses in-process +exception filters BY DESIGN, so .NET's own `DOTNET_DbgEnableMiniDump` +crash handler never fires for this class. Windows' WER LocalDumps is the +OS-level catcher that still does. Today an alpha user can say "it +crashed" but never why. + +**Design (agreed with the owner):** +1. **Launcher owns the WER key, with consent.** First-run opt-in ("save + crash dumps locally") writes + `HKCU\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\AcDream.App.exe` + (HKCU — no elevation): `DumpFolder` inside the launcher's data dir, + `DumpCount` small (e.g. 3), `DumpType=1` (**minidump**, not full — a + minidump carries the faulting stack #422 needs without carrying the + process's whole memory). Uninstall removes the key. +2. **Bundle on next launch.** The launcher already detects the crash exit; + it gathers minidump + client log tail + version + the Vulkan capability + report into one folder and shows "the client crashed last time — the + report is here" with an open-folder button. +3. **NO auto-upload — this is the deliberate line.** Dumps can contain + account names and the plaintext session password in memory; there is no + upload endpoint and no privacy story. The user choosing to send the + folder is consent by construction. Revisit only with real + infrastructure and an explicit consent flow. +4. **Linux:** set `DOTNET_DbgEnableMiniDump=1` / `DOTNET_DbgMiniDumpType` + from the launcher for the non-fail-fast classes; system `core_pattern` + is out of scope. + +**Prior art / constraints:** the project's stance that the CLIENT never +writes registry keys stands — this is launcher-owned, opt-in, per-user. +The owner's dev machine already has the key armed manually (2026-08-24) +for the #422 hunt; this slice productizes that for alpha users, whose +crashes are otherwise invisible telemetry. + +--- + +## #435 — CLOSED: Probe debt: temporary probes outlived their closed investigations or named no owner + +**Status:** CLOSED 2026-08-24 across two passes (`0c5057c9` part 1, +`c1e6e3da` part 2 + the `35454a9f`-adjacent doc correction): 24 probes +deleted, 8 reclassified as standing tools, 1 deleted-and-restored +(`ACDREAM_DUMP_MOVE_TRUTH`, #437 — the canonical soak hard-gates on it). +End state: 137 flags (from 161), 31 temporary probes, every one attributed +to an owning issue or campaign. The default-off invariant is frozen by +`LaunchOptionsDocumentationTests.OnlyTheFourRetailBehaviorFlagsDefaultOn`. +Part-1 record follows. + +**Status (part 1):** The 17 orphaned probes from part 1 are DELETED (2026-08-24) — 3,493 lines removed, flag count 161 → 144, temporary probes 64 → 47. Build clean; full hermetic suite 15,321 passed / 0 failed (baseline 15,333 minus the 12 tests whose only subject was a deleted probe). Four files went From 20721ddadac7afc8653d865c25f20cd8fcd53840 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 13:13:20 +0200 Subject: [PATCH 15/89] =?UTF-8?q?docs:=20open=20Campaign=20CA=20=E2=80=94?= =?UTF-8?q?=20character=20advancement=20retail=20parity=20(promotes=20#431?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner widened #431 into the full advancement family: real-time refresh of vitals maxima (attribute AND direct vital raises), derived skills, run speed under Quickness, attribute-less skills like Salvaging, and the untested train/specialize/respec flows. The promotion survey pinned the root cause: every outbound raise action (0x0044-0x0047) is wired — which is why the GUI 'works' — while the inbound private attribute/skill update family is parsed nowhere (only the vitals pair 0x02E7/0x02E9 is), so the server's post-raise truth never reaches LocalPlayerState and no recompute ever triggers. Plan doc carries the oracle targets (message family from ACE/Chorizite/holtburger, retail's recompute chain in named-retail, specialization/respec semantics) and five slices ending in a user-driven connected gate. #430 tooltips are explicitly sequenced after, on the #409 tooltip system. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 11 +- ...26-08-24-character-advancement-campaign.md | 126 ++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-08-24-character-advancement-campaign.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 7422b455..6e7f66f7 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -417,7 +417,16 @@ the LayoutDesc importer does not mount yet. ## #431 — Raising an attribute does not refresh attribute-derived skills or run speed -**Status:** OPEN +**Status:** PROMOTED to Campaign CA +(`docs/plans/2026-08-24-character-advancement-campaign.md`, 2026-08-24) +after the owner widened the scope to the whole advancement family: vitals +maxima updating on attribute AND direct vital raises, real-time skill +refresh on both attribute and skill raises, run speed responding to +Quickness, attribute-less skills (Salvaging), plus the untested +train/specialize/respec flows. Root cause confirmed at promotion: the +outbound raise actions (0x0044–0x0047) are fully wired — the INBOUND +private attribute/skill update family is parsed nowhere (only the vitals +pair 0x02E7/0x02E9 is), so the recompute trigger never arrives. **Severity:** MEDIUM (visible stat incoherence during play) **Filed:** 2026-08-23 (owner report) **Component:** character state / stat chain / movement speed diff --git a/docs/plans/2026-08-24-character-advancement-campaign.md b/docs/plans/2026-08-24-character-advancement-campaign.md new file mode 100644 index 00000000..69b6fd6e --- /dev/null +++ b/docs/plans/2026-08-24-character-advancement-campaign.md @@ -0,0 +1,126 @@ +# Campaign CA — character advancement retail parity (#431 + the raise/train/specialize family) + +**Status:** ACTIVE 2026-08-24. Owner-directed scope; #431 promoted here. +**Milestone:** M4 — Live in the world. +**Issue anchors:** #431 (promoted), #430 tooltips (SEQUENCED AFTER, not in +this campaign — needs the #409 client-wide tooltip system). + +## Owner's report (2026-08-24, verbatim scope) + +The raise flow "works from the GUI and everything" — the guess is the +refresh side "is not wired correctly." Required behavior: + +1. Raising an attribute must update everything derived from it, in real + time: raising Endurance/Self/etc. must move the **vitals bar** maxima + (health/stamina/mana); raising a **vital directly** (secondary + attributes are XP-raisable too) must update the bar the same way. +2. **Skills must update in real time** both when their underlying + attributes raise and when the skill itself is raised with XP. +3. **Run speed must increase when Quickness is raised** — Run is + attribute-fed. +4. **Skills with no underlying attribute (e.g. Salvaging) need their own + handling** — no formula contribution, raise-only progression. +5. **Unknown/untested territory:** raising skills with XP, and + **specializing** skills; also the retail **respec flow** (quests / + item turn-ins that drop learned skills so a character can re-spec / + re-specialize). None of this has been exercised against ACE. + +## Current-state survey (2026-08-24, verified in source) + +- **Outbound: complete.** `CharacterActions` builds all four actions — + `RaiseVital 0x0044`, `RaiseAttribute 0x0045`, `RaiseSkill 0x0046`, + `TrainSkill 0x0047` — and `CharacterSheetProvider` wires the panel's + buttons to them through the Runtime command seam. This is why the GUI + "works": ACE accepts and applies the raises. +- **Inbound: the hole.** The ONLY private stat-update messages parsed + anywhere are the vitals pair `PrivateUpdateVital (0x02E7)` / + `PrivateUpdateVitalCurrent (0x02E9)`. The attribute and skill update + family ACE sends back after a raise is UNHANDLED — no parser, no + routing, nothing reaches `LocalPlayerState` (whose own doc says + attributes refresh "only at PlayerDescription / future + `PrivateUpdateAttribute`"). Post-raise, the client's attribute/skill + model is stale until the next full PlayerDescription (i.e. next login). +- **Consequences observed by the owner (#431):** derived skills don't + move when an attribute raises; run speed doesn't change with + Quickness. Both follow directly from the missing inbound family — the + recompute never triggers because the trigger never arrives. +- **Run-rate seam already exists:** + `PlayerMovementController.ApplyServerRunRate` (the #431 filing's own + pointer) — the wire echo path updates live run rate; what's missing is + driving it (and the formula-side skill totals) from stat updates. +- **Character state owner:** `RuntimeCharacterState` (J4.3) owns the + spellbook/local-player graph; new stat state routes through it, not + through a parallel store. + +## Oracle targets (CA1 — DO FIRST, no guessing) + +Per the mandatory workflow (grep named-retail → cross-reference ≥2 refs → +pseudocode → port → conformance): + +1. **The inbound message family.** Pin exact opcodes + layouts from ACE + (`GameMessagePrivateUpdateAttribute`, `...Attribute2ndLevel` (vitals), + `...Skill`, `...SkillLevel`, `...SkillAC` as ACE names them; their + sequence-number semantics) cross-checked against Chorizite.ACProtocol + and holtburger. Our `PrivateUpdateVital.cs` already cites ACE's + `GameMessagePrivateUpdateAttribute2ndLevel` naming — extend the same + treatment to the whole family. Also pin what ACE sends for + TrainSkill/specialize responses and for skill-credit changes. +2. **Retail's recompute chain.** In named-retail: how the client applies + an attribute update — which cached values recompute + (`CACQualities::InqSkill` / skill formula with attribute divisors from + SkillTable, max-vital formulas, run-rate refresh via the movement + system). The SkillTable formula fields we already load for chargen + (`ChargenSkillAdvancement`) are the same divisor data — verify the + in-world recompute uses identical math. +3. **Attribute-less skills.** SkillTable rows with no formula + (Salvaging & friends): confirm retail's display/derivation for them + (base = trained ranks + augmentation only). +4. **Training / specialization semantics.** Credits accounting, what the + 0x0047 response looks like, how specialization changes the formula + multiplier (specialized = ranks count differently), and what messages + carry it. +5. **Respec / untrain.** Identify the retail mechanism (quest/item-driven + skill refund) and what the CLIENT sees — expectation: server-driven + property/skill updates using the SAME inbound family, so no bespoke + client flow; verify rather than assume. Confirm ACE's implementation + surface for a test path. + +## Slices + +- **CA1 — oracle + research doc** (`docs/research/2026-08-24-advancement-wire-and-recompute.md`): + everything above, with decomp addresses and ACE file citations. Output: + the pinned message table + retail recompute pseudocode. +- **CA2 — inbound stat-update family.** Parsers for the + attribute/skill/(vital-level) private updates; routed as ordered deltas + into the J4 owners (`RuntimeCharacterState` / `LocalPlayerState`) + through the existing generation-gated seam. Conformance tests from ACE + byte layouts. +- **CA3 — derived recompute + real-time presentation.** One recompute + path (retail's formula) fed by CA2's deltas driving: character panel + skill rows, vitals bar maxima (attribute- and vital-raise both), + run-rate into `ApplyServerRunRate`'s seam, attribute-less skills + handled per oracle. Binding-seam tests (the #436 lesson: assert the + REAL composition binds the refresh, not just that VMs recompute). +- **CA4 — train/specialize/respec verification.** Live-vs-ACE for + TrainSkill + specialization (panel flow + credits), and the respec + path exercised as far as ACE supports; fixes as the oracle demands. +- **CA5 — connected gate script** (`docs/research/2026-08-24-campaign-ca-test-script.md`), + user-driven: raise Quickness → run speed visibly increases immediately; + raise Endurance/Self → vitals maxima move; direct vital raise; skill + raise; train; specialize; respec if ACE path exists. PASS = every + change visible without relog. + +**Sequenced after this campaign:** #430 skill/attribute tooltips (needs +the #409 client-wide tooltip surface; the CA1 oracle should still note +where retail sources its tooltip strings while it is in the +neighborhood). + +## Ledger + +| Slice | Status | Evidence | +|---|---|---| +| CA1 | — | | +| CA2 | — | | +| CA3 | — | | +| CA4 | — | | +| CA5 | — | | From 1fc64984c9218e244816eb3545dff6ca76a38c61 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 13:30:33 +0200 Subject: [PATCH 16/89] =?UTF-8?q?research:=20Campaign=20CA=20slice=20CA1?= =?UTF-8?q?=20=E2=80=94=20advancement=20wire=20+=20retail=20recompute=20or?= =?UTF-8?q?acle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two parallel research passes assembled with a hand-verification ledger on every load-bearing claim. Wire (ACE + Chorizite + holtburger, field-for-field agreement on all six inbound layouts): PrivateUpdateAttribute 0x02E3, PrivateUpdateVital 0x02E7 (always Max-family vital ids 1/3/5), PrivateUpdateAttribute2ndLevel 0x02E9 (always current-family ids 2/4/6 — a parser must NOT treat the two as one id space), PrivateUpdateSkill 0x02DD (ushort ranks + the hardcoded adjustPP=1 pair, f64 lastUsedTime), PrivateUpdatePropertyInt 0x02CD (AvailableSkillCredits=24) and Int64 0x02CF (AvailableExperience=2). Ordered action->response chains for all four raise/train actions, including the retail quirk that an Endurance raise pushes only a HEALTH full-vital record and the client is expected to refresh stamina from it too. Specialize/untrain/reset have NO dedicated opcode — item-Use plus a confirmation round-trip reusing the same update messages. 0x02DF has no ACE producer (verified); CA2 skips it. Recompute (named-retail + live Ghidra): retail computes skills, vitals maxima and run rate LIVE at inquiry time — Set* are raw-storage writes, InqSkill re-derives from the attribute formula every call (verified in the decompile, including the z==0 early-out that IS the attribute-less Salvaging handling and the +10 augmentation adds), InqRunRate runs every motion tick, and UI refresh is a value-less observer notification. Two corrections to our own tree surfaced: PropertyString.cs's comment claims 0x02DD (it is 0x02D5 — doc-only, nothing dispatches on it), and SkillSnapshot.FormulaBonus is frozen at PlayerDescription parse — the stale-cache half of #431 that CA3 replaces with the live computation. RetailSkillFormula.TryCalculate already ports 0x00591960 exactly, so CA3 reuses it rather than porting anew. Co-Authored-By: Claude Fable 5 --- ...26-08-24-character-advancement-campaign.md | 2 +- ...26-08-24-advancement-wire-and-recompute.md | 1256 +++++++++++++++++ 2 files changed, 1257 insertions(+), 1 deletion(-) create mode 100644 docs/research/2026-08-24-advancement-wire-and-recompute.md diff --git a/docs/plans/2026-08-24-character-advancement-campaign.md b/docs/plans/2026-08-24-character-advancement-campaign.md index 69b6fd6e..e4a28833 100644 --- a/docs/plans/2026-08-24-character-advancement-campaign.md +++ b/docs/plans/2026-08-24-character-advancement-campaign.md @@ -119,7 +119,7 @@ neighborhood). | Slice | Status | Evidence | |---|---|---| -| CA1 | — | | +| CA1 | COMPLETE 2026-08-24 | docs/research/2026-08-24-advancement-wire-and-recompute.md — six inbound messages pinned byte-for-byte with 3-source agreement; live-at-inquiry recompute verdict verified by hand in Ghidra; RetailSkillFormula already ports 0x00591960 exactly | | CA2 | — | | | CA3 | — | | | CA4 | — | | diff --git a/docs/research/2026-08-24-advancement-wire-and-recompute.md b/docs/research/2026-08-24-advancement-wire-and-recompute.md new file mode 100644 index 00000000..e16dcfb8 --- /dev/null +++ b/docs/research/2026-08-24-advancement-wire-and-recompute.md @@ -0,0 +1,1256 @@ +# CA1 — advancement wire protocol + retail recompute chain (Campaign CA oracle) + +Assembled 2026-08-24 from two parallel research passes (wire: ACE + +Chorizite + holtburger; recompute: named-retail pseudo-C + live Ghidra +decompiles of the PDB-paired 2013 binary), with every load-bearing claim +independently re-verified before assembly (§0). + +## 0. Verification ledger (claims re-checked by hand before use) + +| Claim | Verified how | Verdict | +|---|---|---| +| `PrivateUpdateSkill = 0x02DD` (our `PropertyString.cs` comment says 0x02DD is PropertyString) | ACE `GameMessageOpcode.cs`: Skill=0x02DD, **PropertyString=0x02D5** | Agent right; OUR comment wrong (doc-only — nothing dispatches on either id today; fix the comment in CA2) | +| `SkillFormula::Calculate @ 0x00591960` = `floor((x·a1+y·a2+w)/z+0.5)`, `z==0 → 0` | Ghidra decompile, read in full | EXACT — and the `z==0` early-out IS the attribute-less-skill (Salvaging) handling; no special case needed | +| `CACQualities::InqSkill @ 0x00592660` computes LIVE (base formula + ranks + init + augmentation bonuses) | Ghidra decompile, read in full | CONFIRMED — includes the per-skill-group +10 augmentation adds (our #268 family) | +| ACE's `HandleRunRateUpdate` wire effect | `Player.cs:975` read in full | Re-broadcasts the movement packet with new ForwardSpeed when rate changed mid-movement → lands in our existing `UpdateMotion.ForwardSpeed` echo → `ApplyServerRunRate` | +| `PrivateUpdateSkillLevel (0x02DF)` producer | grep ACE Source | NO producer anywhere (opcode-name table only) — vestigial for ACE; CA2 skips the parser | +| Retail formula vs our code | `src/AcDream.App/Net/RetailSkillFormula.cs` | **ALREADY PORTED EXACTLY** (cites 0x00591960; unsigned reinterpretation of DAT signed views; Trained+5/Specialized+10 from CC5-F3). ACE's simplified `AttributeFormula` is NOT in our code path | + +**Design verdict binding CA2/CA3:** retail computes skills, vitals maxima +and run rate LIVE at inquiry time; Set* handlers are raw-storage writes; +UI refresh is a value-less observer notification and widgets re-pull. +Therefore acdream's port: inbound parsers write raw stats into the J4 +owners, `SkillSnapshot`'s frozen `FormulaBonus` becomes a live computation +against current attributes, and presentation re-reads on the existing +change events. No recompute cascade, no dirty flags, no cached effective +values. + +--- + +# Campaign CA / Slice CA1 — character-advancement wire research + +Read-only research. All ACE / Chorizite.ACProtocol / holtburger file:line +citations below point at `references//...` **as checked out in the +main repo** (`C:\Users\erikn\source\repos\acdream\references\...`). Those +three reference repos are **not present in this worktree** (they are +untracked/gitignored and worktrees don't get untracked files) — only +`references/WorldBuilder` exists here. When implementing, either read the +main checkout directly or re-verify paths resolve in whatever tree you're +in. + +Our existing outbound builder: `src/AcDream.Core.Net/Messages/CharacterActions.cs` +(this worktree). Our existing (partial) inbound parser: +`src/AcDream.Core.Net/Messages/PrivateUpdateVital.cs` (this worktree, vitals only). + +--- + +## 1. Summary opcode table + +| Dir | Opcode | Name (ACE) | Name (Chorizite) | Purpose | +|---|---|---|---|---| +| C→S | `0xF7B1` | `GameActionOpcode.GameAction` (envelope) | — | Wraps every GameAction: `u32 opcode=0xF7B1, u32 sequence, u32 subOpcode, ...body` | +| C→S | `0x0044` | `GameActionType.RaiseVital` | `Train_TrainAttribute2nd` | Spend XP to raise a vital | +| C→S | `0x0045` | `GameActionType.RaiseAttribute` | `Train_TrainAttribute` | Spend XP to raise an attribute | +| C→S | `0x0046` | `GameActionType.RaiseSkill` | `Train_TrainSkill` | Spend XP to raise a skill | +| C→S | `0x0047` | `GameActionType.TrainSkill` | `Train_TrainSkillAdvancementClass` | Spend skill credits to train a skill | +| S→C | `0x02CD` | `PrivateUpdatePropertyInt` | `Qualities_PrivateUpdateInt` | `AvailableSkillCredits` after TrainSkill / specialize / untrain / reset | +| S→C | `0x02CF` | `PrivateUpdatePropertyInt64` | `Qualities_PrivateUpdateInt64` | `AvailableExperience` after **every** successful RaiseAttribute/RaiseVital/RaiseSkill | +| S→C | `0x02DD` | `PrivateUpdateSkill` | `Qualities_PrivateUpdateSkill` | Full skill record (ranks/SAC/xp/init/resistance/lastUsed) | +| S→C | `0x02DF` | `PrivateUpdateSkillLevel` | (not generated under that name; opcode exists in enum) | Ranks-only skill delta — **NOT used by any of the 4 actions**; not emitted by `HandleActionRaiseSkill`/`HandleActionTrainSkill`/`SkillAlterationDevice`. holtburger still models it (`UpdateSkillLevel`) but no ACE producer for it was found for this feature surface. Flagged as an open question below. | +| S→C | `0x02E3` | `PrivateUpdateAttribute` | `Qualities_PrivateUpdateAttribute` | Full attribute record (ranks/start/xp) | +| S→C | `0x02E7` | `PrivateUpdateVital` | `Qualities_PrivateUpdateAttribute2nd` *(name disagreement, same bytes — see §5)* | Full vital record (ranks/start/xp/current) | +| S→C | `0x02E9` | `PrivateUpdateAttribute2ndLevel` | `Qualities_PrivateUpdateAttribute2ndLevel` | Current-only vital delta (no ranks/xp) | +| S→C | `0xF750` | `Sound` | — | `RaiseTrait` sound cue on rank-up | +| S→C | `0xF7E0` | `ServerMessage` (→ `GameMessageSystemChat`) | — | "Your base X is now N!" text on rank-up; also failure text | +| S→C (GameEvent, envelope `0xF7B0`) | GameEventType `0x028B` | `WeenieErrorWithString` | — | Specialize/untrain/reset success/failure text (routed through a confirmation dialog, not a plain GameMessage) | + +No dedicated opcode exists for **untrain / specialize / reset** — see §4. + +--- + +## 2. Per-message byte layout + +All multi-byte integers are little-endian. Every `GameMessage` subclass in +ACE auto-writes its own `u32 Opcode` first via the `GameMessage` base +class ctor (`references/ACE/Source/ACE.Server/Network/GameMessages/GameMessage.cs:25-26` +/ `:45-46`) — that 4 bytes is **not** written again by the derived class +body; I've included it in each layout below for wire-completeness. + +### 2.1 Outbound — `RaiseVital` (`0x0044`) + +``` +u32 envelope = 0xF7B1 +u32 sequence +u32 subOpcode = 0x0044 +u32 vitalId // PropertyAttribute2nd — MUST be the Max* id (1/3/5), see §2.9 +u32 xpSpent +``` +20 bytes total. Confirmed by: +- ACE parse: `references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionRaiseVital.cs:11-12` + (`(PropertyAttribute2nd)message.Payload.ReadUInt32()`, then `ReadUInt32()` for xp — **both u32**, not u64). +- holtburger: `RaiseVitalActionData` — `vital_type: u32, xp_spent: u32` — + `references/holtburger/crates/holtburger-protocol/src/messages/player/actions.rs:38-65`. +- Chorizite: `Train_TrainAttribute2nd` — `Type: VitalId (u32), Experience: uint` — + `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/C2S/Actions/Train_TrainAttribute2nd.generated.cs:14-41`. + +Our builder (`CharacterActions.BuildAttrOrVital`, `src/AcDream.Core.Net/Messages/CharacterActions.cs:94-103`) +matches exactly. **No change needed** for the outbound side. + +### 2.2 Outbound — `RaiseAttribute` (`0x0045`) + +Same shape as 2.1: `u32 attrId (PropertyAttribute) + u32 xpSpent`. +- ACE parse: `GameActionRaiseAttribute.cs:10-11`. +- holtburger: `RaiseAttributeActionData` — `actions.rs:8-27`. +- Chorizite: `Train_TrainAttribute` — `Train_TrainAttribute.generated.cs:14-41`. +Matches our builder. + +### 2.3 Outbound — `RaiseSkill` (`0x0046`) + +`u32 skillId (Skill enum) + u32 xpSpent`. +- ACE parse: `GameActionRaiseSkill.cs:10-11` — `(Skill)message.Payload.ReadUInt32()`, `ReadUInt32()`. +- holtburger: `RaiseSkillActionData` — `skill_type: u32, xp_spent: u32` — `actions.rs:67-94`. +- Chorizite: `Train_TrainSkill` reads `Skill = (SkillId)reader.ReadInt32()` (**signed** read) — + `Train_TrainSkill.generated.cs:30`. Same 4 bytes on the wire; interpretation differs (see §5). +Matches our builder. + +### 2.4 Outbound — `TrainSkill` (`0x0047`) + +`u32 skillId + i32 creditsSpent` (**signed**, unlike the other three actions' xp field). +- ACE parse: `GameActionTrainSkill.cs:10-11` — `(Skill)message.Payload.ReadUInt32()`, then + **`message.Payload.ReadInt32()`** (signed) for `creditsSpent`. +- holtburger: `TrainSkillActionData { skill_type: u32, credits_spent: i32 }`, unpacked with + `read_i32` — `actions.rs:97-116`, and its own unit test packs credits as + `write_i32::` — `actions.rs:277-299`. +- Chorizite: `Train_TrainSkillAdvancementClass.Credits` is declared `uint` and read via + `reader.ReadUInt32()` — `Train_TrainSkillAdvancementClass.generated.cs:23,31`. **Disagreement** with + ACE/holtburger's signed read — see §5. Byte layout is identical either way (credits are always + small positives on the wire). +Our builder (`BuildTrainSkill`, `CharacterActions.cs:51-60`) writes credits as `uint` — bytes are +correct; only the C# type differs from ACE's server-side signedness, which is harmless. + +### 2.5 Inbound — `PrivateUpdateAttribute` (`0x02E3`) + +``` +u32 opcode = 0x02E3 +u8 sequence // ByteSequence, see §3 +u32 attribute // PropertyAttribute (1=Strength..6=Self) +u32 ranks // CreatureAttribute.Ranks (uint) +u32 startingValue // CreatureAttribute.StartingValue == PropertiesAttribute.InitLevel (uint) +u32 experienceSpent // CreatureAttribute.ExperienceSpent == PropertiesAttribute.CPSpent (uint) +``` +21 bytes. ACE producer + field order: +`references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessagePrivateUpdateAttribute.cs:8-16` +(`Writer.Write(seq)` line 11 [via `GetNextSequence`, 1 byte — see §3], `(uint)Attribute` line 12, +`Ranks` line 13, `StartingValue` line 14, `ExperienceSpent` line 15). +All four fields are `uint` in `PropertiesAttribute`: +`references/ACE/Source/ACE.Entity/Models/PropertiesAttribute.cs:7-9`. + +Cross-check: Chorizite's `AttributeInfo` struct — `PointsRaised(u32), InnatePoints(u32), +ExperienceSpent(u32)` in that exact order — +`references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/AttributeInfo.generated.cs:26-54` +(`PointsRaised`=Ranks, `InnatePoints`=StartingValue). holtburger's `UpdateAttribute` — +`sequence: u8, attribute: u32, ranks: u32, start: u32, xp: u32` — +`references/holtburger/crates/holtburger-protocol/src/messages/player/types.rs:22-53`. +**All three agree exactly**, field-for-field. + +### 2.6 Inbound — `PrivateUpdateVital` (`0x02E7`, "full" update) + +``` +u32 opcode = 0x02E7 +u8 sequence +u32 vitalId // PropertyAttribute2nd — ALWAYS the Max* id (1/3/5), see §2.9 +u32 ranks // CreatureVital.Ranks (uint) +u32 startingValue // CreatureVital.StartingValue (uint) +u32 experienceSpent // CreatureVital.ExperienceSpent (uint) +u32 current // CreatureVital.Current == PropertiesAttribute2nd.CurrentLevel (uint) +``` +25 bytes. ACE producer: +`references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessagePrivateUpdateVital.cs:8-18`. +Field source types: `references/ACE/Source/ACE.Entity/Models/PropertiesAttribute2nd.cs:7-10` +(all `uint`, including `CurrentLevel`). + +**IMPORTANT — this is ACE's `GameMessagePrivateUpdateVital`, but Chorizite's generated name for +the SAME opcode/bytes is `Qualities_PrivateUpdateAttribute2nd`** (not `...Vital`). Confirmed +identical layout: `Attribute: AttributeInfo (PointsRaised/InnatePoints/ExperienceSpent) + Current: u32` — +`references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/S2C/Qualities_PrivateUpdateAttribute2nd.generated.cs:14-55` ++ `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/SecondaryAttributeInfo.generated.cs:22-50`. +holtburger's `UpdateVital` (`PrivateUpdateVitalData`) matches field-for-field: +`sequence, vital, ranks, start, xp, current` — `types.rs:137-184`, with a golden-fixture test at +`types.rs:296-307` (`ranks:100, start:12345, xp:67890, current:100`). + +Our existing doc comment in `PrivateUpdateVital.cs:24-33` already has this layout right. +**No correction needed** to the existing `TryParseFull`. + +### 2.7 Inbound — `PrivateUpdateAttribute2ndLevel` (`0x02E9`, "current-only" delta) + +``` +u32 opcode = 0x02E9 +u8 sequence +u32 vitalId // Vital enum (ACE.Entity.Enum.Vital) — the NON-Max variant: Health=2/Stamina=4/Mana=6 +u32 current +``` +13 bytes. ACE producer: +`references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessagePrivateUpdateAttribute2ndLevel.cs:8-14`. + +Cross-check: Chorizite's `Qualities_PrivateUpdateAttribute2ndLevel` — `Sequence(byte), Key(u32, +`CurVitalId`), Value(u32)` — +`references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/S2C/Qualities_PrivateUpdateAttribute2ndLevel.generated.cs:14-55`. +holtburger's `UpdateVitalCurrent` (`PrivateUpdateVitalCurrentData`) matches: +`sequence, vital, current` — `types.rs:189-225`, fixture test at `types.rs:330-339`, and an explicit +raw-bytes unit test at `types.rs:377-397` (`0x0C` seq, `0x00000002` vital=Health, `0x00000064` +current=100) confirming the **current-only message's vital id is `Health`=2, not `MaxHealth`=1**. + +Our existing doc (`PrivateUpdateVital.cs:34-40`) already has this layout right, but its comment +"ACE Vital enum (1=MaxHealth..6=Mana)" doesn't disambiguate that this specific opcode (0x02E9) +**always** carries the non-Max id while 0x02E7 **always** carries the Max id — see §2.9 for the +mechanism. Worth tightening that comment when the attribute/skill parsers are added alongside it. + +### 2.8 Inbound — `PrivateUpdateSkill` (`0x02DD`) + +``` +u32 opcode = 0x02DD +u8 sequence +u32 skillId // Skill enum (0=None, ordinal-numbered — see §6 open question) +u16 ranks // CreatureSkill.Ranks == PropertiesSkill.LevelFromPP (ushort!) +u16 adjustPP // hardcoded constant = 1 on every send (see comment below) +u32 advancementClass // SkillAdvancementClass (CreatureSkill.AdvancementClass == PropertiesSkill.SAC) +u32 experienceSpent // CreatureSkill.ExperienceSpent == PropertiesSkill.PP (uint) +u32 initLevel // CreatureSkill.InitLevel == PropertiesSkill.InitLevel (uint) +u32 resistanceAtLastCheck // PropertiesSkill.ResistanceAtLastCheck (uint) +f64 lastUsedTime // PropertiesSkill.LastUsedTime (double, 8 bytes) +``` +37 bytes. ACE producer, with the `adjustPP` local hardcoded to `1` and the comment "If this is +not 0, it appears to trigger the initLevel to be treated as extra XP applied to the skill": +`references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessagePrivateUpdateSkill.cs:8-24`. +Field source types: `references/ACE/Source/ACE.Entity/Models/PropertiesSkill.cs:9-14` +(`LevelFromPP: ushort`, `SAC: SkillAdvancementClass`, `PP: uint`, `InitLevel: uint`, +`ResistanceAtLastCheck: uint`, `LastUsedTime: double`). + +Cross-check: Chorizite's `Skill` struct — `PointsRaised(u16), AdjustPP(u16), TrainingLevel(u32), +ExperienceSpent(u32), InnatePoints(u32), ResistanceOfLastCheck(u32), LastUsedTime(f64/double)` in +that exact order — +`references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/Skill.generated.cs:22-86`. holtburger's +`UpdateSkill` matches too, reading ranks/adjustPP as `u16` then widening to its `u32` struct +field: `sequence, skill, ranks(u16), adjust_pp(u16), status, xp, init, resistance, last_used(f64)` — +`references/holtburger/crates/holtburger-protocol/src/messages/player/types.rs:71-131`, with a +golden fixture at `types.rs:279-293` (`ranks:50, adjust_pp:1, status:3, xp:1000, init:10, +resistance:0, last_used:0.0`) — confirms `adjustPP` is `1` in a real captured fixture, matching +ACE's hardcoded constant. +**All three agree exactly.** + +### 2.9 Inbound — `PrivateUpdatePropertyInt` (`0x02CD`) + +``` +u32 opcode = 0x02CD +u8 sequence +u32 propertyId // PropertyInt +i32 value +``` +13 bytes. ACE producer: +`references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessagePrivateUpdatePropertyInt.cs:9-15`. +Cross-check: Chorizite's `Qualities_PrivateUpdateInt` — `Sequence(byte), Key(u32, PropertyInt), +Value(int32)` — +`references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/S2C/Qualities_PrivateUpdateInt.generated.cs:14-56`. +Matches. + +`PropertyInt.AvailableSkillCredits = 24` — +`references/ACE/Source/ACE.Entity/Enum/Properties/PropertyInt.cs:43`. + +### 2.10 Inbound — `PrivateUpdatePropertyInt64` (`0x02CF`) + +``` +u32 opcode = 0x02CF +u8 sequence +u32 propertyId // PropertyInt64 +i64 value +``` +17 bytes. ACE producer: +`references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessagePrivateUpdatePropertyInt64.cs:8-14`. +Cross-check: Chorizite's `Qualities_PrivateUpdateInt64` matches +(`Sequence(byte), Key(u32), Value(int64)`) — +`references/Chorizite.ACProtocol/Chorizite.ACProtocol/Messages/S2C/Qualities_PrivateUpdateInt64.generated.cs:14-56`. + +`PropertyInt64.AvailableExperience = 2` — +`references/ACE/Source/ACE.Entity/Enum/Properties/PropertyInt64.cs:14`. + +### 2.11 The Max-vs-current vital id split (mechanism) + +Two *different* enums, numerically identical, are used depending on message: + +- `PropertyAttribute2nd` (`references/ACE/Source/ACE.Entity/Enum/Properties/PropertyAttribute2nd.cs:8-14`): + `Undef=0, MaxHealth=1, Health=2, MaxStamina=3, Stamina=4, MaxMana=5, Mana=6`. This is what + `Creature.Vitals` is keyed by, and **only the Max* keys exist in that dictionary**: + `Vitals[PropertyAttribute2nd.MaxHealth] = ...` etc. — + `references/ACE/Source/ACE.Server/WorldObjects/Creature.cs:98-100`. So `HandleActionRaiseVital`'s + inbound `vital` parameter, and the id embedded in the full `PrivateUpdateVital` (0x02E7) message + (`creatureVital.Vital`), are **always one of {1, 3, 5}**. +- `Vital` (`references/ACE/Source/ACE.Entity/Enum/Vital.cs`): `Undefined=0, MaxHealth=1, Health=2, + MaxStamina=3, Stamina=4, MaxMana=5, Mana=6` — same numbering, used only for the current-only + message. `CreatureVital.ToEnum()` maps `MaxHealth→Vital.Health(2)`, `MaxStamina→Vital.Stamina(4)`, + `MaxMana→Vital.Mana(6)` — + `references/ACE/Source/ACE.Server/WorldObjects/Entity/CreatureVital.cs:203-212`, and + `UpdateVital()` sends `new GameMessagePrivateUpdateAttribute2ndLevel(this, vital.ToEnum(), + vital.Current)` — `references/ACE/Source/ACE.Server/WorldObjects/Player_Vitals.cs:139`. So the + 0x02E9 message's id is **always one of {2, 4, 6}**. + +Net: **a client-side parser must NOT assume a single id space for "vital" across both opcodes** — +0x02E7 always carries {1,3,5}, 0x02E9 always carries {2,4,6}. holtburger's raw-bytes unit test +(`types.rs:377-397`) is the clean confirming fixture for the 0x02E9 case (vital=2=Health). + +--- + +## 3. Sequence-number scheme + +Every "sequence" byte above is **1 byte**, produced by `ISequence.NextBytes` (returns `new byte[] +{ NextValue }`) — `references/ACE/Source/ACE.Server/Network/Sequence/ByteSequence.cs:30-45`. The +containing `GameMessage*` constructors call `Writer.Write(byte[])`, which writes the array's raw +bytes with **no length prefix**, so exactly 1 byte lands on the wire per sequence field. This +matches what our existing `PrivateUpdateVital.cs` doc already asserts ("single byte... not 4 bytes +despite some ACE writer signatures looking like uint"). + +**Per-(type, property) counters.** `SequenceManager.GetSequence(type, property)` keys its +dictionary by `(uint)type << 16 | property` — +`references/ACE/Source/ACE.Server/Network/Sequence/SequenceManager.cs:152-156` — so every distinct +`(SequenceType, propertyId)` pair gets its **own independent** counter. Concretely: raising +Strength and raising Endurance advance two different byte counters (`UpdateAttribute+1` vs +`UpdateAttribute+2`); raising two different skills advance two different counters; the +`AvailableExperience` counter (`UpdatePropertyInt64+2`) is independent of all attribute/skill/vital +counters. + +**Which `SequenceType` bucket each message uses, and its allocation strategy** +(`SequenceManager.GetSequence`, `:160-181`): + +| Wire message | `SequenceType` used | Bucket strategy | +|---|---|---| +| `PrivateUpdateAttribute` (0x02E3) | `UpdateAttribute` | falls to `default:` → `ByteSequence(false)` | +| `PrivateUpdateVital` (0x02E7, full) | `UpdateAttribute2ndLevel` | `default:` → `ByteSequence(false)` | +| `PrivateUpdateAttribute2ndLevel` (0x02E9, current) | `UpdateAttribute2ndLevel` | `default:` → `ByteSequence(false)` — **same bucket as the full message**, keyed by vital property, so 0x02E7 and 0x02E9 traffic for the *same vital* share one incrementing byte counter (see note below) | +| `PrivateUpdateSkill` (0x02DD) | `UpdateSkill` | `default:` → `ByteSequence(false)` | +| `PrivateUpdatePropertyInt` (0x02CD) | `UpdatePropertyInt` | `default:` → `ByteSequence(false)` | +| `PrivateUpdatePropertyInt64` (0x02CF) | `UpdatePropertyInt64` | `default:` → `ByteSequence(false)` | + +`ByteSequence(false)` means `clientPrimed=false` → `CurrentValue` starts at `maxValue` (255); the +first `NextValue` call detects `CurrentValue == maxValue`, resets to 0, and returns 0 — so **every +counter's first observed value is 0**, then increments 1, 2, 3, ... wrapping back to 0 after 255 +(`references/ACE/Source/ACE.Server/Network/Sequence/ByteSequence.cs:19-26,30-41`). None of the +`ObjectPosition/Movement/State/Vector/.../Motion` special-cased 16-bit sequence types apply here — +all six advancement-family sequence types fall through to the byte-sized default. + +**Shared counter caveat (0x02E7 vs 0x02E9):** `GameMessagePrivateUpdateVital`'s ctor calls +`GetNextSequence(SequenceType.UpdateAttribute2ndLevel, creatureVital.Vital)` (line 11 of that file) +— i.e. it uses the **same** `SequenceType.UpdateAttribute2ndLevel` bucket, keyed by the **same** +`PropertyAttribute2nd` vital value, as the current-only message's `GetNextSequence(..., +vital.ToEnum())` call. Because `Vital` (0x02E9's key type) and `PropertyAttribute2nd` (0x02E7's key +type) share numeric values 1:1, and `SequenceManager.GetSequence` takes a raw `uint property` +(`:27-30`, `:152`), a full 0x02E7 update for MaxHealth(1) and a current-only 0x02E9 tick for +Health(2) hit **different dictionary keys** (`key = type<<16 | 1` vs `type<<16 | 2`) — so they do +**not** actually share a counter across message types; each of the six (3 vitals × 2 opcodes) gets +its own independent byte sequence. (Correcting an initial read of mine: same `SequenceType` enum +value, but different `property` argument value, means different dictionary key. No cross-opcode +sharing.) + +--- + +## 4. Action → response mapping + +### 4.1 `RaiseAttribute` (0x0045) → `HandleActionRaiseAttribute` +(`references/ACE/Source/ACE.Server/WorldObjects/Player_Attributes.cs:13-73`) + +1. Validate: attribute exists, `amount <= AvailableExperience`. On failure: **no attribute update + sent**; if `SpendAttributeXp` itself fails, sends `GameMessageSystemChat` ("Your attempt to + raise {attr} has failed.", `ChatMessageType.Broadcast`) and returns. +2. `SpendAttributeXp` → `SpendXP(amount, sendNetworkUpdate=true)` → + **`PrivateUpdatePropertyInt64` (0x02CF)** with `AvailableExperience` (new value) — + `references/ACE/Source/ACE.Server/WorldObjects/Player_Xp.cs:352-363`. This fires on **every** + successful spend, rank-up or not. +3. **`PrivateUpdateAttribute` (0x02E3)** — always sent on success, rank-up or not + (`Player_Attributes.cs:35`). +4. If `prevRank != creatureAttribute.Ranks` (i.e. an actual rank-up occurred): + - if max rank reached: a particle-effect broadcast (`PlayParticleEffect`, not a private + GameMessage to this session) and a `" and has reached its upper limit"` suffix. + - **`GameMessageSound`** (`Sound.RaiseTrait`) + **`GameMessageSystemChat`** + ("Your base {attribute} is now {Base}{suffix}!", `ChatMessageType.Advancement`) — both sent + together (`Player_Attributes.cs:48-51`). + - if `attribute == Endurance`: an **extra `PrivateUpdateVital` (0x02E7) full update for + Health** (`Player_Attributes.cs:56-58`) — comment states this "appears to trigger client to + update both health and stamina" client-side, even though **only Health's full record is sent + on the wire, not Stamina's**. A client parser needs to recompute both Health and Stamina + derived display values from this single Health packet if it wants to match retail's stated + behavior — this is a genuine, non-obvious retail/ACE quirk, not a bug to "fix". + - if `attribute == Self`: an extra `PrivateUpdateVital` (0x02E7) full update for **Mana** + (`Player_Attributes.cs:60-64`). + - if `attribute` is `Strength` or `Quickness` and the `runrate_add_hooks` property is on: + `HandleRunRateUpdate()` (affects run-speed derived state, not itself a GameMessage here). + +Ordered wire sequence for a successful Endurance raise that ranks up: +`PrivateUpdatePropertyInt64(AvailableExperience)` → `PrivateUpdateAttribute(0x02E3)` → +`GameMessageSound` + `GameMessageSystemChat` (same `EnqueueSend` call, so adjacent) → +`PrivateUpdateVital(0x02E7, Health)`. + +### 4.2 `RaiseVital` (0x0044) → `HandleActionRaiseVital` +(`references/ACE/Source/ACE.Server/WorldObjects/Player_Vitals.cs:20-65`) + +1. Validate: vital exists in `Vitals` (so **must** be keyed by a Max* id — see §2.9), + `amount <= AvailableExperience`. On failure (amount check): sends + `GameMessageSystemChat` ("Your attempt to raise {vital} has failed.", + `ChatMessageType.Broadcast`) — **note ACE's own comment**: "there is a client bug for vitals + only, where the client will enable the button to raise a vital by 10 if the player only has + enough AvailableExperience to raise it by 1" (`Player_Vitals.cs:30-33`) — a known retail client + quirk worth preserving if we're porting client-side gating logic, not "fixing". +2. `SpendVitalXp` → `SpendXP` → **`PrivateUpdatePropertyInt64` (0x02CF)** `AvailableExperience`. +3. **`PrivateUpdateVital` (0x02E7)** full update — always sent on success (`Player_Vitals.cs:46`). +4. If rank-up: max-rank particle effect + suffix; **`GameMessageSound`(RaiseTrait)** + + **`GameMessageSystemChat`** ("Your base {vital} is now {Base}{suffix}!", + `ChatMessageType.Advancement`) (`:59-62`). **No cross-vital side effect** here (unlike + RaiseAttribute's Endurance/Self special cases) — raising a vital directly only ever touches that + one vital's full record. + +### 4.3 `RaiseSkill` (0x0046) → `HandleActionRaiseSkill` +(`references/ACE/Source/ACE.Server/WorldObjects/Player_Skills.cs:21-67`) + +1. Validate: creature skill exists and `AdvancementClass >= Trained` (untrained skills can't be + raised by XP directly), `amount <= AvailableExperience`. Failure: silent (`log.Warn` only, + **no chat message sent** — unlike RaiseAttribute/RaiseVital's failure paths, `HandleActionRaiseSkill` + does not send `GameMessageSystemChat` on the "trained/specialized skill not found" or + "amount > AvailableExperience" branches, `:27-35`). +2. `SpendSkillXp` → `SpendXP` → **`PrivateUpdatePropertyInt64` (0x02CF)** `AvailableExperience`. +3. **`PrivateUpdateSkill` (0x02DD)** — always sent on success (`Player_Skills.cs:42`). +4. If rank-up: max-rank particle effect + suffix; **`GameMessageSound`(RaiseTrait)** + + **`GameMessageSystemChat`** ("Your base {skill} skill is now {Base}{suffix}!", + `ChatMessageType.Advancement`) (`:56-59`). If `skill == Run`: `HandleRunRateUpdate()` (same + run-speed hook as RaiseAttribute's Strength/Quickness case, gated by the same + `runrate_add_hooks` property). + +### 4.4 `TrainSkill` (0x0047) → `HandleActionTrainSkill` +(`references/ACE/Source/ACE.Server/WorldObjects/Player_Skills.cs:114-153`) + +1. Validate: `creditsSpent <= AvailableSkillCredits`; skill base exists in the DAT skill table; + **`creditsSpent` must exactly equal `skillBase.TrainedCost`** (server re-derives the cost from + the DAT and rejects any client-sent value that doesn't match — `:129-133`). Any validation + failure: silent server-side `log.Warn`, **no GameMessage sent at all** (function returns before + reaching the `success`/`else` branch that sends anything). +2. Calls `TrainSkill(skill, creditsSpent)` (a different overload, `:171-197`) which sets + `AdvancementClass = Trained`, resets `Ranks`/`InitLevel`/`ExperienceSpent`, and debits + `AvailableSkillCredits -= creditsSpent` **in-process** (no network send inside this inner + method — the caller sends the messages). +3. On success: + - **`PrivateUpdateSkill` (0x02DD)** for the now-trained skill. + - **`PrivateUpdatePropertyInt` (0x02CD)** `AvailableSkillCredits` (new value). + - **`GameMessageSystemChat`** ("{skill} trained. You now have {N} credits available.", + `ChatMessageType.Advancement`). + - All three sent together in one `EnqueueSend(updateSkill, skillCredits, msg)` call + (`:145-147`) — **no `GameMessageSound` here**, unlike Raise*'s rank-up path. + - **Note: `TrainSkill` does NOT send a `PrivateUpdatePropertyInt64` (AvailableExperience) + update** — training costs skill credits, not XP, so `AvailableExperience` is untouched. +4. On failure of the inner `TrainSkill(skill, creditsSpent)` call (only reachable if + `AdvancementClass >= Trained` already, i.e. re-training an already-trained/specialized skill, or + a credits race): **`GameMessageSystemChat`** ("Failed to train {skill}! You now have {N} credits + available.", `ChatMessageType.Advancement`) — **no skill/credit update messages** on this path. + +### 4.5 Specialize / Untrain / Unspecialize / Reset — no dedicated opcode + +Confirmed by exhaustive scan of `GameActionType` (`references/ACE/Source/ACE.Server/Network/GameAction/GameActionType.cs`) +— there is **no** `Specialize`/`Untrain`/`ResetSkill`/`ResetAttribute` entry in the 0x00xx–0x03xx +GameAction range. `Player_Skills.cs` has server-side methods `SpecializeSkill`, `UntrainSkill`, +`UnspecializeSkill`, `ResetSkill` (`:199-309`, `:865-921`) but **none of them carry a +`[GameAction]` attribute** — they're invoked from other systems, not directly from a client +opcode: + +- **`SkillAlterationDevice`** (Gem of Enlightenment = specialize, Gem of Forgetfulness = lower) — + the item's `ActOnUse` is reached via the **generic `Use` action (`0x0036`)**, not a + progression-specific opcode + (`references/ACE/Source/ACE.Server/WorldObjects/SkillAlterationDevice.cs:58-103`). It then routes + through a **confirmation dialog** (`ConfirmationManager.EnqueueSend(new + Confirmation_AlterSkill(...))`, `:96`) — a separate ask/confirm wire round-trip (client replies + via `ConfirmationResponse = 0x0275`), not part of this feature's direct action→response chain. + Once confirmed, `AlterSkill` (`:169-230`) sends, per branch: + - Specialize success: **`PrivateUpdateSkill`(0x02DD)** + **`PrivateUpdatePropertyInt`(0x02CD, + AvailableSkillCredits)** + a **`GameEventWeenieErrorWithString`** (GameEvent sub-type + `0x028B`, inside the `0xF7B0` GameEvent envelope — a different envelope from the plain + `GameMessage` family used everywhere else in this doc) carrying + `YouHaveSucceededSpecializing_Skill` + the skill name (`:176-185`). Layout of + `GameEventWeenieErrorWithString`: `u32 errorType + String16L message` — + `references/ACE/Source/ACE.Server/Network/GameEvent/Events/GameEventWeenieErrorWithString.cs:7-13`. + - Lower (specialized→trained via `UnspecializeSkill`) or (trained→untrained via `UntrainSkill`) + success: same two `PrivateUpdateSkill` + `PrivateUpdatePropertyInt` pair, with a different + `WeenieErrorWithString` message id per exact transition (`:196-227`). +- **`Enlightenment`** (100th-level Fianhe reset path) directly manipulates `player.Vitals[attribute]` + and sends `GameMessagePrivateUpdateVital` — `references/ACE/Source/ACE.Server/Entity/Enlightenment.cs:218-220` — + again reached through a non-progression trigger (an enlightenment/temple interaction, not a + GameAction opcode of its own). +- Generic **`ResetSkill`** (`Player_Skills.cs:865-921`) sends **`PrivateUpdateSkill`(0x02DD)** + + **`PrivateUpdatePropertyInt`(0x02CD, AvailableSkillCredits)** + **`GameMessageSystemChat`** + (plain chat text built in C#, not a `WeenieErrorWithString` id) — same three-message shape as + TrainSkill's success path, minus the `GameMessageSound`. + +**Answer to "is there an UNTRAIN or specialize action opcode":** No. Retail/ACE routes all +specialize/lower/reset flows through item-Use (`0x0036`) + a confirmation round-trip, or through +non-player-initiated systems (Enlightenment). If CA1 needs to support these, the response-message +shapes above (2× GameMessage + 1× GameEvent, all reusing opcodes already covered in §2) are +sufficient — no new opcode needs to be learned, but the **GameEvent envelope (`0xF7B0`) and its +sub-opcode framing** are a different code path from the plain `GameMessage` opcodes this doc +otherwise covers, and weren't otherwise in scope for CA1's four core actions. + +--- + +## 5. Disagreements between references + +1. **`TrainSkill` credits field signedness.** ACE's handler reads it with + `message.Payload.ReadInt32()` (signed) — `GameActionTrainSkill.cs:11` — and holtburger's + `TrainSkillActionData.credits_spent` is `i32`, unpacked with `read_i32` — `actions.rs:99,109`. + Chorizite's `Train_TrainSkillAdvancementClass.Credits` is declared `uint` and read via + `reader.ReadUInt32()` — `Train_TrainSkillAdvancementClass.generated.cs:23,31`. **Genuine + type-level disagreement** (ACE+holtburger say signed, Chorizite says unsigned). No practical + wire effect since credits are always small non-negative values, but if CA1 code asserts a type, + prefer **signed `int`** to match the two independent oracles (ACE server source + a working + client) over the generated Chorizite stub. +2. **Message class naming for opcode `0x02E7`.** ACE calls it `GameMessagePrivateUpdateVital` + (full vital record). Chorizite's generator calls the identical-opcode, identical-byte-layout + type `Qualities_PrivateUpdateAttribute2nd`. Not a byte-level disagreement — purely a naming + collision that could cause confusion when grepping Chorizite for "Vital" and finding nothing (as + happened during this research pass — see §"open questions" for how this was discovered). If our + own parser code introduces names, prefer ACE's naming (`PrivateUpdateVital`) since that's also + what our existing `PrivateUpdateVital.cs` already uses. +3. **`RaiseSkill`/`TrainSkill` skill-id read signedness.** ACE reads skill ids for `RaiseSkill` as + `(Skill)message.Payload.ReadUInt32()` (unsigned) but for `TrainSkill` conceptually the same + field is also `(Skill)message.Payload.ReadUInt32()` — **both unsigned** on the ACE side + (`GameActionRaiseSkill.cs:10`, `GameActionTrainSkill.cs:10`). Chorizite reads **both** as + `(SkillId)reader.ReadInt32()` (signed) — `Train_TrainSkill.generated.cs:30`, + `Train_TrainSkillAdvancementClass.generated.cs:30`. holtburger's `RaiseSkillActionData.skill_type` + is `u32` (unsigned) — `actions.rs:69`, `:78`; its `TrainSkillActionData.skill_type` is also `u32` + (only `credits_spent` is signed) — `actions.rs:98,107`. So Chorizite is the outlier reading + skill id as signed on **both** actions; ACE+holtburger agree it's unsigned. Byte-identical + either way since valid skill ids are small positive ordinals (§6 notes the exact numbering isn't + pinned in this pass). +4. **No disagreement found** on any of the six inbound message byte layouts (§2.5–§2.10) — ACE, + Chorizite, and holtburger's independent implementations agree field-for-field, including the + unusual `ushort ranks / ushort adjustPP` pair inside `PrivateUpdateSkill` and the hardcoded + `adjustPP=1` constant (confirmed present in a holtburger golden-fixture test, not just inferred + from ACE source). + +--- + +## 6. Open questions / not settled from the references + +1. **`PrivateUpdateSkillLevel` (`0x02DF`) / `PublicUpdateSkillLevel` (`0x02E0`) producer not + found.** The opcode exists in ACE's `GameMessageOpcode` enum and holtburger models a full + `UpdateSkillLevel` struct for it (`types.rs:230-269`, with fixture tests at `:353-375`), but I + did not find any ACE call site constructing a `GameMessagePrivateUpdateSkillLevel` (in fact, no + such class file exists in ACE's `GameMessages/Messages/` directory at all — only + `GameMessagePrivateUpdateSkill.cs`, the *full*-opcode 0x02DD one). Either (a) ACE genuinely never + emits this ranks-only delta and it's vestigial/retail-only, or (b) a producer exists elsewhere I + didn't search (e.g. a bulk/batch skill-sync path outside `Player_Skills.cs` and + `SkillAlterationDevice.cs`). **Not required for CA1's four actions** (none of their response + chains use it), but flag before assuming the opcode is dead — a targeted `grep -r + PrivateUpdateSkillLevel references/ACE` beyond what this pass covered would settle it. +2. **Exact numeric `Skill` enum ordinals** were not fully enumerated — I confirmed the enum is + ordinal-numbered starting at `None=0` with several `/* Retired */`/`/* Unimplemented */` gaps + preserved in-place (`references/ACE/Source/ACE.Entity/Enum/Skill.cs:11-40+`), and holtburger's + test comments corroborate two spot values (`6 = MeleeDefense`, `14 = ArcaneLore`, + `references/holtburger/crates/holtburger-protocol/src/messages/player/actions.rs:270,283`), but + I did not transcribe the full ~60-entry table. If acdream doesn't already have a `Skill` enum + under `src/AcDream.Core`, the CA1 implementer should port the full ordinal list directly from + `references/ACE/Source/ACE.Entity/Enum/Skill.cs` verbatim (order matters — it's explicitly + documented as never-reorderable). +3. **`Sound.RaiseTrait`'s numeric id** and **`ChatMessageType.Advancement`'s numeric id** were not + looked up (both are straightforward enum lookups in `references/ACE/Source/ACE.Entity/Enum/` if + needed for exact-byte test fixtures). +4. **Whether raising a vital's rank ever auto-adjusts `Current`** wasn't traced end-to-end. The + full `PrivateUpdateVital` message always includes `creatureVital.Current` as one of its fields + (§2.6), but `HandleActionRaiseVital`/`SpendVitalXp` only touch `Ranks`/`ExperienceSpent` — I did + not find code in that call chain that tops up `Current` to match a newly raised `MaxValue`. If + retail visibly bumps current HP/stamina/mana immediately on a vital raise, that adjustment (if + any) happens somewhere outside the files read in this pass (possibly the vital heartbeat tick + picking it up on the next 5s cycle, given `VitalHeartBeat()` exists as a separate mechanism — + `Player_Vitals.cs:196-205`). Confirm with a live capture before assuming instant top-up. + +--- + +# CA1 — retail attribute/skill/vitals recompute chain + +Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR +build, PDB-named) + `acclient.h` (verbatim retail structs) + Ghidra MCP clean +decompiles (`patchmem.gpr`, same binary) for every function relied on below. +Cross-checked against `references/ACE/Source/ACE.Server` and +`references/ACE/Source/ACE.DatLoader`. All addresses are file offsets == RVA +(x86, base 0x400000 not added — matches the pseudo-C left column). + +**Headline verdict (drives the whole CA1 design): the retail client computes +skills, vitals maxima, and run rate LIVE, at inquiry time, from raw stored +attribute/skill data. There is no cached "effective skill" field and no +explicit recompute-cascade when an attribute changes. UI refresh is a +separate, decoupled observer mechanism (`QualityRegistrar`) that only tells a +widget "id X changed, re-pull it" — it never carries the new value.** + +--- + +## 1. Inbound handler map + +All inbound private/public quality-update wire messages funnel through one +C++ template, `ClientObjMaintSystem::::UpdateStat`, +instantiated once per (QualityType, wire-value-shape) pair. The **public** +handlers (`Handle_Qualities__Update*`, updates for an arbitrary observed +weenie) and the **private** handlers (`Handle_Qualities__PrivateUpdate*`, +always target the local player via `SmartBox::smartbox->player_id`) both +funnel into the same template body — the private ones just supply +`player_id` instead of a wire-carried target id. + +Confirmed handler entry points (`ClientObjMaintSystem::Handle_Qualities__*`): + +| Handler | Address | Wire value shape | Delegates to | +|---|---|---|---| +| `UpdateAttribute` | `0x00558c80` | full `Attribute` struct | `UpdateStat` | +| `UpdateAttributeLevel` | `0x00558ca0` | raw `unsigned long` | `UpdateStat` | +| `UpdateAttribute2nd` | `0x00558cc0` | full `SecondaryAttribute` struct | `UpdateStat` | +| `UpdateAttribute2ndLevel` | `0x00558ce0` | raw `unsigned long` | `UpdateStat` | +| `UpdateSkill` | `0x00558d00` | full `Skill` struct | `UpdateStat` | +| `UpdateSkillLevel` | `0x00558d20` | raw `unsigned long` | `UpdateStat` | +| `UpdateSkillAC` | `0x00558d40` | `SKILL_ADVANCEMENT_CLASS` enum | `UpdateStat` | +| `PrivateUpdateAttribute` | `0x00558e80` | full `Attribute` struct, target = local player | same template, `player_id` forced | +| `PrivateUpdateAttributeLevel` | `0x00558eb0` | raw ulong, target = local player | " | +| `PrivateUpdateAttribute2nd` | `0x00558ee0` | full struct, target = local player | " | +| `PrivateUpdateAttribute2ndLevel` | `0x00558f10` | raw ulong, target = local player | " | +| (Skill private variants follow the identical pattern at nearby addresses in the same block, `0x00558f4x`–`0x00558fcx`) | | | | + +Body of every `UpdateStat` instantiation for Attribute/Attribute2nd/Skill +(example, the raw-`unsigned long` Attribute overload, `0x00557d00`): + +``` +UpdateStat(this, qualityId, timestampByte, targetWeenieId, newValue): + weenie = CObjectMaint::GetWeenieObject(this, targetWeenieId) + if weenie == null: return 0 + if !ACCWeenieObject::SetupStamper(weenie): return 0 // per-object dedupe/anti-replay stamp + if !WTimeStamper::UpdateTS(weenie.stamper, qualityId | 0x80000, timestampByte): + return 0 // stale/out-of-order update, drop + qualities = weenie.pQualities // CACQualities* at weenie+0x14c + if qualities != null: + qualities.SetAttribute(qualityId, newValue) // <-- PURE RAW STORAGE WRITE (§below) + ACCWeenieObject::OnStatUpdated(weenie, qualityId, newValue) // only on the *raw-value* overloads (see caveat) + if QualityRegistrar::s_pQR != null: + QualityRegistrar::s_pQR.CallChangeHandler(weenie, Attribute_StatType, qualityId) // <-- THE UI notify (§7) + return 0 +``` + +Every `SetX` call (`CACQualities::SetAttribute` `0x00591a50`/`0x00591aa0`, +`SetAttribute2nd` `0x00592530`/`0x005925f0`/`0x00592cf0`, `SetSkill` +`0x00592240`, `SetSkillLevel` `0x00592340`, `SetSkillAdvancementClass` +`0x00592430`) was decompiled directly and **each one is a pure raw-storage +write** — no recompute, no dirty flag, no dependent-field touch: + +- `SetAttribute` → lazily allocates `AttributeCache` if absent, then + `AttributeCache::SetAttribute(id, {init_level, level_from_cp, cp_spent})` + overwrites those three fields verbatim (`0x005cc740`). +- `SetSkill`/`SetSkillLevel` → hash-table upsert into + `CACQualities::_skillStatsTable` (`PackableHashTable`), + copying `_sac, _pp, _init_level, _level_from_pp, + _resistance_of_last_check, _last_used_time` verbatim. +- `SetSkillAdvancementClass` → same hash table, writes only `_sac` via + `Skill::SetSkillAdvancementClass`. + +**`ACCWeenieObject::OnStatUpdated` caveat (OPEN, non-load-bearing):** two +concrete overload bodies were found in the binary, `0x0058c680` (switches on +PropertyBool ids: Stuck=1, Openable=3, Inscribable=0x16, UIHidden=0x18, +CellBarrierImmune=0x19, HiddenAdmin=0x1a) and `0x0058df20` (switches on +PropertyInt ids: Type=1, Priority=4, ItemsCapacity=6, …, HookItemTypes=0x98). +**Neither switch has a case matching the Attribute/Attribute2nd/Skill id +space** (1–6, 0x1f–0x32), so whichever of the two the compiler resolved the +raw-scalar `UpdateStat` call to, it is a silent no-op for attribute/skill ids +— this function does **not** participate in character-advancement recompute. +I could not pin the exact overload resolved at each of the 7 call sites +(BinaryNinja's rendered parameter types are ambiguous between `int32_t` and +`long` at this ABI); it doesn't change the answer to Q1, so left open. + +**Verdict for Q1: an inbound stat update writes the raw number/struct +straight into `CACQualities`'s storage and fires one generic +`QualityRegistrar::CallChangeHandler(weenie, StatType, id)` notification. +It never calls anything that resembles "recompute skill" or "recompute +vitals max" — those are computed by the *reader*, not the writer.** + +--- + +## 2. The skill formula — LIVE at inquiry time (not cached) + +### Call chain + +`CACQualities::InqSkill(this, skillId, out int, includeRaw)` @ `0x00592660` +(the "effective" overload; there's also a bare struct-copy `InqSkill` +overload at `0x005919df` unrelated to computation) calls +`InqSkillBaseLevel` @ `0x00592140` first, then layers bonuses on top. + +**`InqSkillBaseLevel`** (attribute contribution — the "base" the game shows +before augmentations): + +``` +InqSkillBaseLevel(this, skillId, out value, rawAttrFlag): + skillTable = DBObj::GetByEnum(4, 2, 0x10000004) // the portal.dat SkillTable singleton + if skillTable == null: return 0 + base = skillTable.GetSkillBase(skillId) // SkillBase record: _min_level, _formula, costs... + if base == null: return 0 + + sac = UNTRAINED + entry = this._skillStatsTable?.lookup(skillId) + if entry != null: sac = entry._sac + + if (int)sac < (int)base._min_level: // gate: skill not usable at this SAC yet + value = 0 + return 1 // NOTE: still returns success=1, value forced 0 + + a1 = 0; a2 = 0 + if base._formula._attr1 != 0: InqAttribute(this, base._formula._attr1, &a1, rawAttrFlag) + if base._formula._attr2 != 0: InqAttribute(this, base._formula._attr2, &a2, rawAttrFlag) + return SkillFormula::Calculate(base._formula, a1, a2, &value) +``` + +**`SkillFormula::Calculate`** @ `0x00591960` — the actual math, verbatim +(struct `SkillFormula { uint _w, _x, _y, _z, _attr1, _attr2; }`, +`acclient.h:40199`): + +``` +Calculate(formula, attr1Value, attr2Value, out result): + if formula._z == 0: return 0 // divisor 0 => invalid formula, caller treats as failure + numerator = formula._x * attr1Value + formula._y * attr2Value + formula._w + result = floor((float)numerator / (float)formula._z + 0.5) // round-half-up via floor(x+0.5) + return 1 +``` + +**`InqAttribute(this, attrId, out value, rawFlag)`** @ `0x005919d0`/`0x00591a00` +— the value that feeds the formula is itself live: + +``` +InqAttribute(this, attrId, out value, rawFlag): + if this._attribCache == null: return 0 + if !AttributeCache::InqAttribute(this._attribCache, attrId, &value): return 0 + if rawFlag == 0: + EnchantAttribute(this, attrId, &value) // CEnchantmentRegistry::EnchantAttribute — live active-spell lookup + return 1 +``` + +`AttributeCache::InqAttribute` (`0x005cc4e0`) is a flat per-attribute struct +store (`_strength/_endurance/_quickness/_coordination/_focus/_self`, each a +heap `Attribute{_init_level,_level_from_cp,_cp_spent}`); it returns +`_init_level + _level_from_cp` — exactly the two fields `SetAttribute` +overwrites on every wire update. **There is no third place that stores a +"current effective attribute" — the value that comes back is always the +freshly-stored number plus whatever `CEnchantmentRegistry` says right now.** + +### Back in `InqSkill` (`0x00592660`), after the base level: + +``` +InqSkill(this, skillId, out value, rawFlag): + if !InqSkillBaseLevel(this, skillId, &value, rawFlag): return 0 + + entry = this._skillStatsTable?.lookup(skillId) + if entry != null: value += entry._level_from_pp + entry._init_level // trained ranks + chargen bonus + + // PropertyInt 0x16d = LumAugAllSkills — flat, unconditional, ALWAYS applied (even rawFlag==1) + if InqInt(0x16d, &bonus) && bonus > 0: value += bonus + + // category-gated flat +10, keyed by skill id -> which "AugmentationSkilledX" PropertyInt to check + // skill in {0x1f,0x20,0x21,0x22,0x2b} -> PropertyInt 0x12e (302, AugmentationSkilledMagic) + // skill in {0x29,0x2c,0x2d,0x2e,0x31} -> PropertyInt 0x12c (300, AugmentationSkilledMelee) + // skill == 0x2f -> PropertyInt 0x12d (301, AugmentationSkilledMissile) + if augBonusInt > 0: value += 10 + + if rawFlag == 0: // "raw"==0 means "give me the effective/enchanted value" + EnchantSkill(this, skillId, &value) // CEnchantmentRegistry::EnchantSkill — live spell buffs + VITAE live here + // PropertyInt 0x146 (326, AugmentationJackOfAllTrades) -- flat +5, added AFTER enchant/vitae + if InqInt(0x146, &b2) && b2 > 0: value += 5 + // PropertyInt 0x158 (344, LumAugSkilledSpec) -- doubled, SPECIALIZED-only, added AFTER enchant/vitae + InqInt(0x158, &b3) + if b3 > 0 and entry?._sac == SPECIALIZED: + value += b3 * 2 + return 1 +``` + +`EnchantSkill`/`EnchantAttribute`/`EnchantAttribute2nd` (`0x0058f0b0` / +`0x0058f070` / `0x0058f090`) are one-line delegates to +`CEnchantmentRegistry::EnchantSkill/EnchantAttribute/EnchantAttribute2nd` — +the client's live table of currently-active spell effects. Nothing about +these calls is memoized; they walk the registry fresh every call. + +### Verdict + +**LIVE, confirmed by direct trace, not inference.** An attribute raise +(`SetAttribute` overwriting `AttributeCache`) requires **zero** explicit +follow-up to make skills reflect it — the next `InqSkill`/`InqSkillBaseLevel` +call re-reads `AttributeCache` and re-runs `SkillFormula::Calculate`. Same +for a spell buff landing (`CEnchantmentRegistry` update) or an SAC change. +**CA1 should port this as a pure function `Skill.Current(attributes, trainedState, activeEnchantments, augmentations, vitae) -> uint`, called on demand by every UI/gameplay reader — not as a cached field refreshed by an event handler.** + +--- + +## 3. Vitals maxima (MaxHealth/MaxStamina/MaxMana) + +Same formula engine, different DBObj table. `CACQualities::InqAttribute2nd` +overload with `(id, out uint, rawFlag)` @ `0x00592020`: + +``` +InqAttribute2nd(this, id, out value, rawFlag): + base = 0 + if id in {1,3,5}: // 1=MaxHealth,3=MaxStamina,5=MaxMana (odd ids; ACE PropertyAttribute2nd matches exactly) + if !InqAttribute2ndBaseLevel(this, id, &base, rawFlag): return 0 + + if id == 1: // MaxHealth only + if InqInt(0x17b /* 379 = PropertyInt.GearMaxHealth */, &gear) && gear: + base += gear // flat item-granted Max Health bonus, added to "base" tier + + if this._attribCache != null and AttributeCache::InqAttribute2nd(cache, id, &cached) != 0: + value = cached + base // "current" ids (2,4,6) hit ONLY this branch: cached holds the live current HP/SP/mana, base==0 for those ids + else: + if base == 0: return 0 + value = base + + if rawFlag == 0: + EnchantAttribute2nd(this, id, &value) // live spell buffs on the vital (max or current) + return 1 +``` + +`InqAttribute2ndBaseLevel` @ `0x00591d20` mirrors `InqSkillBaseLevel` +exactly but reads from a different global singleton, +`DBObj::GetByEnum(1, 2, 0x10000003)` = the `Attribute2ndTable` DBObj +(`struct Attribute2ndTable { Attribute2ndBase _max_health, _max_stamina, _max_mana; }`, +`acclient.h:40216`, each an `Attribute2ndBase{ SkillFormula _formula; }`) — +id 1→`_max_health`, 3→`_max_stamina`, 5→`_max_mana`, feeding the **same** +`SkillFormula::Calculate` used for skills. Concretely: MaxHealth/MaxStamina +are driven by the Endurance-weighted formula and MaxMana by the Self-weighted +formula, but the exact `_attr1/_attr2/_w/_x/_y/_z` values are DAT data, not +hardcoded in this function — the mechanism is confirmed, the numeric +coefficients were not independently dumped (OPEN, low-value: any live +`portal.dat` read or ACE's `SecondaryAttributeTable` gives them for free). + +Note the asymmetry: **odd ids (max) run the formula; even ids (current +Health/Stamina/Mana) are pure `AttributeCache` reads** (server-pushed current +value), never formula-derived. `Attribute2nd` id 2/4/6 in `AttributeCache` +is exactly what `PrivateUpdateAttribute2ndLevel` (`0x00558f10`) overwrites. + +### Bounds enforcement on the *current* value + +`CACQualities::BoundsCheck` @ `0x005920e0`, called from the +`SetAttribute2nd(id, rawValue, ...)` wrapper (`0x005925f0`) whenever id is +one of the *current* ids (2,4,6): + +``` +BoundsCheck(this, id, ref value, out maxOut): + if id not in {2,4,6}: return 1 // only current HP/SP/mana are clamped + if value < 0: value = 0; return 1 + if !InqAttribute2nd(this, id-1, &maxOut, rawFlag=0): return 0 // id-1 = the paired max id; LIVE lookup + if maxOut < value: value = maxOut + return 1 +``` + +So the current-value SET path itself pulls a **live** Max via +`InqAttribute2nd` to clamp — one more confirmation there's no cached max +anywhere the client trusts. + +### UI refresh trigger for the vitals bar (ties into §7) + +`Attribute2ndInfoRegion::Attribute2ndInfoRegion` (`0x004f1680`) — the vitals +bar row widget's ctor — registers **three or more** separate +`QualityRegistrar::RegisterQualityHandlerForThePlayer(Attribute_2nd_StatType=9, id, this)` +subscriptions: + +1. `m_CurAttribute` (e.g. Health=2) +2. `m_MaxAttribute` (`= m_CurAttribute - 1`, e.g. MaxHealth=1) +3. It then reads the `Attribute2ndTable` record for the max id + (`Attribute2ndTable::InqAttribute2ndBase`) and, **if the formula's + `_attr1`/`_attr2` are non-zero, ALSO registers for those ids** — under + `StatType 9` again, even though the contributing attribute (e.g. + Endurance) is a base `Attribute` that only ever fires + `CallChangeHandler(Attribute_StatType=8, ...)`. This third registration + therefore looks structurally dead/vestigial for the base-attribute case — + see the note at the end of §7. It doesn't change the answer to "what + triggers the bar to redraw": the confirmed, load-bearing triggers are (1) + and (2), i.e. **the vitals bar redraws when the SERVER pushes an explicit + `PrivateUpdateAttribute2nd`/`...Level` wire message for that specific + current-or-max id — not from a local recompute cascade.** + +### Verdict for Q3 + +Formula: **live**, same mechanism/engine as skills (`SkillFormula::Calculate` +over the `Attribute2ndTable` DAT record), confirmed by direct trace. +Trigger: **the vitals bar's redraw is driven by `QualityRegistrar` +notifications tied 1:1 to the wire ids the server actually pushes** (own +current id + own max id). The client is fully capable of computing MaxHealth +locally from Endurance without any server push (§2's live chain applies +here too), but the UI's *redraw* event only fires on an explicit wire update +to that exact (StatType, id) — so **retail's server is the one that decides +when to recompute-and-push an updated MaxHealth/MaxStamina/MaxMana whenever +the underlying attribute changes; the client formula exists for the value +itself, not as the UI's dirty-trigger.** CA1 must mirror this: acdream's +server-authoritative path (ACE) is expected to push its own recomputed +vitals-max update on an Endurance/Self change — verify ACE actually does +this (see §8) rather than relying on a purely-local recompute-on-attribute- +change UI hook, or the vitals bar will silently stop matching retail's +redraw cadence even though the *number* would still be correct next time +anything else forces a redraw. + +--- + +## 4. Run rate — LIVE, re-derived every motion tick (not event-driven at all) + +`CACQualities::InqRunRate` @ `0x00592800` **inlines its own copy** of the +InqSkill formula chain for skill id `0x18` (Run) rather than calling +`InqSkill` — presumably to add one extra rule: + +``` +InqRunRate(this, out rate): + if !InqLoad(this, &loadFactor): return 0 // encumbrance multiplier, 1.0 = unencumbered + if this._attribCache == null: return 0 + if !AttributeCache::InqAttribute2nd(cache, 4 /*Stamina, current*/, &curStamina): return 0 + EnchantAttribute2nd(this, 4, &curStamina) // live buffs on current Stamina + if !InqSkillBaseLevel(this, 0x18, &runSkill, rawFlag=0): return 0 + + entry = _skillStatsTable?.lookup(0x18) + if entry: runSkill += entry._level_from_pp + entry._init_level + if InqInt(0x16d) > 0: runSkill += bonus // LumAugAllSkills + EnchantSkill(this, 0x18, &runSkill) // live spell buffs + vitae + if InqInt(0x146) > 0: runSkill += 5 // JackOfAllTrades + if InqInt(0x158) > 0 and entry?._sac == SPECIALIZED: runSkill += InqInt(0x158) * 2 // LumAugSkilledSpec + + if curStamina == 0: // <-- FATIGUE RULE, Run-specific + runSkill = 0 // exhausted (0 current Stamina) => cannot use Run skill at all + + rate = MovementSystem::GetRunRate(loadFactor, runSkill, 1.0) + return 1 +``` + +`InqMaxRunRate` @ `0x00591b20` is just +`MovementSystem::GetRunRate(0.0 /* full load */, 9999 /* max skill */, 1.0)` +— a theoretical ceiling, not tied to the player at all. + +### Who calls it, and how often + +Four call sites, all in `CMotionInterp` (the per-entity motion interpolator), +all on `weenie_obj->InqRunRate(...)` (virtual dispatch through +`ACCWeenieObject::InqRunRate` `0x0058c560` → `CACQualities::InqRunRate`): + +- `CMotionInterp::apply_run_to_command` @ `0x00527be0` — scales a forward/ + sidestep command's magnitude when a new motion command is applied. +- `CMotionInterp::get_max_speed` @ `0x00527cb0` +- `CMotionInterp::get_adjusted_max_speed` @ `0x00527d00` +- `CMotionInterp::get_state_velocity` @ `0x00527d50` — computes the actual + per-tick velocity vector; this one runs on the motion-interpolation + cadence (every tick the interpolator advances), not just on command + change. + +**All four fall back to `this->my_run_rate` (a plain cached float on +`CMotionInterp`) if `InqRunRate` returns 0.** `InqRunRate` returns 0 whenever +`this._attribCache == null` — i.e. whenever the weenie doesn't carry a live, +fully-populated `CACQualities` (every weenie *other than the local player*: +remote players and monsters only get a thin/partial qualities projection +over the wire, not full trained-attribute data). + +### Where `my_run_rate` (the fallback) is set + +Found exactly two write sites, both inside the inbound movement-event +parser handling `MoveToObject` (case 6) and `MoveToPosition` (case 7) motion +commands (`0x005245e9` / `0x00524656`, inside the function starting near +`0x00524460`): + +``` +case MoveToObject / MoveToPosition: + MovementParameters::UnPackNet(¶ms, kind, wire) // unpacks the wire MovementParameters struct + speed = *(float*)wire; wire += 4 // an explicit speed/run-rate float on the wire + this.motion_interpreter.my_run_rate = speed + ... +``` + +### Verdict for Q4 + +**Local player**: `InqRunRate` is called **every tick** the motion +interpolator needs a velocity — there is no cache, no "recompute on Run +skill change" event at all; the fatigue check (current Stamina == 0) and the +whole skill-formula chain are re-evaluated on every call. **CA1 should port +`InqRunRate` as a per-tick pure query** (attributes + skill state + +enchantments + current Stamina → run rate), exactly mirroring how it already +treats skills. + +**Remote weenies (and any weenie without a full qualities projection)**: +run rate is **not** derived locally at all — it's a cached float taken +verbatim from an explicit `speed` field on the server's `MoveToObject`/ +`MoveToPosition` wire payload (`MovementParameters`), applied once per +motion command, held until the next one. This directly confirms the design +already noted in CLAUDE.md/`ACDREAM_RUN_SKILL` docs +(`PlayerMovementController.ApplyServerRunRate`, echoing +`UpdateMotion.ForwardSpeed`): **retail itself does the same +local-computes/remote-trusts-the-wire split** — acdream's existing dual-path +shape is retail-faithful, not an adaptation. (One nuance to verify against +current acdream code: retail's wire-sourced fallback is populated from the +`MovementParameters` speed field carried on `MoveToObject`/`MoveToPosition` +specifically, not from every `UpdateMotion` — worth a follow-up check that +acdream's actual sync point matches this exact message, not a broader one.) + +--- + +## 5. Attribute-less skills (e.g. Salvaging) + +Confirmed by code inspection of `InqSkillBaseLevel` (§2): the two +`InqAttribute` calls are individually gated — +`if base._formula._attr1 != 0: InqAttribute(...)` and same for `_attr2`. If +a `SkillBase._formula` has `_attr1 == 0` and `_attr2 == 0` (no attribute +contribution authored for that skill), both `a1` and `a2` stay `0`, and +`SkillFormula::Calculate` reduces to: + +``` +result = floor((_x*0 + _y*0 + _w) / _z + 0.5) = floor(_w/_z + 0.5) +``` + +i.e. a pure constant (the formula's `_w`/`_z` bias, presumably 0/1 in +practice for a true attribute-less skill, giving `result = 0`). **This +degrades gracefully — no null-check, no divide-by-zero risk** (the only +divide-by-zero guard is `_z == 0 => return 0`, unrelated to attr1/attr2 +being zero). Everything downstream (trained ranks, augmentations, +enchantments) applies identically regardless of whether the attribute term +contributed anything. + +**OPEN**: which specific skill ids have `_attr1==_attr2==0` in the shipped +`portal.dat` SkillTable was not independently confirmed here (that's DAT +data, not code — the pseudo-C only proves the *mechanism* handles it +correctly). A live DatCollection read of `SkillTable` (0x10000004) would +give the exact list; ACE's own `references/ACE/Source/ACE.DatLoader` reads +the same table and could be probed against a live install if needed for +CA1 test fixtures. + +--- + +## 6. Specialization semantics (SKILL_ADVANCEMENT_CLASS) + +`enum SKILL_ADVANCEMENT_CLASS { UNDEF=0, UNTRAINED=1, TRAINED=2, +SPECIALIZED=3, NUM=4 }` (`acclient.h:2951`). Two, and only two, places the +runtime formula reads SAC (everything else — `_trained_cost`/ +`_specialized_cost` on `SkillBase` — is chargen/skill-credit-spending data, +never touched by `InqSkill`/`InqSkillBaseLevel`/`InqRunRate`): + +1. **Usability gate** (`InqSkillBaseLevel`): `if (int)sac < (int)base._min_level: value = 0`. + A skill authored with `_min_level = TRAINED` reads as 0 for an untrained + character; one authored `_min_level = UNTRAINED` (i.e. always usable) is + never gated. This is purely a floor-to-zero, not a formula change — the + attribute/formula computation for a *usable* skill is identical between + Trained and Specialized. +2. **`LumAugSkilledSpec` (PropertyInt 0x158/344) doubling**, in both + `InqSkill` and the inlined copy in `InqRunRate`: the augmentation's own + int value is added **only if the skill's own SAC == SPECIALIZED**, and + when it applies it's doubled (`value += aug * 2`), vs. not applied at all + for Trained. This is the *only* place SAC changes the numeric formula at + runtime. + +There is **no** "+10 for specialized / +5 for trained" retail-side constant +bonus baked into the client's runtime inquiry math — that folklore number +(confirmed in ACE's own `CreatureSkill.InitLevel` doc comment: "A bonus from +character creation: +5 for trained, +10 for specialized") is a **chargen-time +one-shot** value baked directly into `_init_level` when the character is +created (part of the flat number `SetSkill`/`SetSkillLevel` write into +storage), not something the runtime formula re-derives from SAC on every +call. `InqSkillBaseLevel`/`InqSkill` just add whatever `_init_level` already +holds — they never branch on SAC to decide it. **CA1 must not port a +"branch on SAC, add 5 or 10" step into the runtime formula — that number +belongs entirely to chargen (already covered by +`ChargenSkillAdvancement.cs`/CC's completed work), and shows up in the +runtime formula only as the already-baked `_init_level` field.** + +Trained-cost/specialized-cost fields on `SkillBase` +(`_trained_cost`,`_specialized_cost`) are the CP-cost-to-raise-SAC numbers +used by the chargen/skill-credit UI, confirmed unused by any of the +`Inq*`/`SkillFormula::Calculate` call chains traced above. + +--- + +## 7. UI refresh mechanism + +`QualityRegistrar` (`acclient.h:33347`, +`struct { vfptr; IntrusiveHashTable m_handlers; QualityHandler m_PlayerQualityHandler; QualityHandler m_GlobalQualityHandler; }`) +is a genuine observer/pub-sub registry, singleton `QualityRegistrar::s_pQR`, +built by `CFactory::MakeQualityRegistrar_Internal` (`0x0054af80`). Its vtable +(`acclient.h:33363`): + +``` +RegisterQualityHandler(weenieId, StatType, qualityId, QualityChangeHandler*) +RegisterQualityHandlerForThePlayer(StatType, qualityId, QualityChangeHandler*) +UnRegisterQualityHandler(...) / UnRegisterQualityHandlerForThePlayer(...) +CallChangeHandler(weenie, StatType, qualityId) // fired by every SetX wire handler, §1 +``` + +`QualityChangeHandler`'s vtable (`acclient.h:33237`) is exactly two methods: + +``` +OnQualityChanged(CWeenieObject* owner, StatType type, uint qualityId) +OnQualityRemoved(CWeenieObject* owner, StatType type, uint qualityId) +``` + +**Critically, the callback carries no value** — only "this (StatType, id) +just changed on this owner." Every UI widget that cares about a live number +must pull it itself. Confirmed concretely for the character-panel attribute +row: `AttributeInfoRegion::AttributeInfoRegion` (`0x004f1530`) ends its ctor +with `RegisterQualityHandlerForThePlayer(Attribute_StatType=8, +this->m_Attribute, this)`; `Attribute2ndInfoRegion::Attribute2ndInfoRegion` +(`0x004f1680`) does the equivalent for the vitals bar (§3); +`SkillInfoRegion`-style rows use `Skill_StatType=4` the same way (seen at +`0x004f2172`: `InfoRegion::InfoRegion(this, ..., Skill_StatType, skillId, +iconDID)`, base ctor only builds the label/value `UIElement`s — the +subclass ctor is what registers). + +`StatType` enum (`acclient.h:2879`) used as the registry's namespace key: +`Int=1, Float=2, Position=3, Skill=4, String=5, DataID=6, InstanceID=7, +Attribute=8, Attribute_2nd=9, BodyDamageValue=0xA, BodyDamageVariance=0xB, +BodyArmorValue=0xC, Bool=0xD, Int64=0xE`. + +### Verdict for Q7 + +Event-driven, not per-frame polling and not a dirty-flag scan. Registration +is per-widget, per-(StatType,id), keyed either to a specific weenie or to +"whichever weenie is currently the local player" +(`RegisterQualityHandlerForThePlayer`, which is what every character-panel/ +vitals-bar row uses — it survives player-object swaps, e.g. on +login/character-switch, without re-registering). **The push only says +"something changed"; the UI re-derives the actual number via the same live +`Inq*` chain described in §2/§3/§4.** This is the cleanest possible design +for CA1 to port: one `IQualityChangeNotifier` (weenie, StatType, id) event, +fired from the same write path identified in §1, with every panel/HUD +element subscribing per-id and re-pulling its value on notification rather +than caching a pushed value. + +**Loose end (non-blocking, flagged for awareness):** `Attribute2ndInfoRegion` +also registers under `Attribute_2nd_StatType` for the *base-attribute* ids +that feed the max-vital formula (e.g. Endurance's id, when the vitals row is +MaxHealth). Base-attribute changes fire `CallChangeHandler` under +`Attribute_StatType=8`, not `9` — so that third registration looks like it +never actually fires from a base-attribute change in this build. Given §3's +finding that the vitals bar's real, working trigger is the server's own +explicit MaxHealth/MaxStamina/MaxMana push, this looks like either +vestigial/defensive code or a targeted id-space that happens to coincide by +accident; it is not load-bearing for CA1's design and I would not port it +without further evidence it does anything. + +--- + +## 8. ACE / chargen cross-check + +### `src/AcDream.Core/CharGen/ChargenSkillAdvancement.cs` + +Already ported the **correct** formula *shape*: +`ChargenSkillFormula(AdditiveBonus, Attribute1Multiplier, Attribute2Multiplier, +Divisor, Attribute1, Attribute2)` is documented as sourced from +`gmCGSkillsPage::MakeSkillFormula @0x00480e10` and maps 1:1 onto +`SkillFormula{_w,_x,_y,_z,_attr1,_attr2}` — verified: chargen's own compute +path (`0x005c4bfb`, inside `CharGenState`-related code) calls the **same** +`SkillFormula::Calculate` (`0x00591960`) used by the live runtime path in §2. +So chargen and post-login runtime share one formula engine in retail, and +`ChargenSkillAdvancement.cs`'s field layout is provably correct for CA1 to +reuse/extend. + +**Gap found**: `ChargenSkillFormula` today is *data only* — the one consumer +(`CharacterCreationSkillsPage.cs::ComposeFormula`) only builds a **display +string** ("Str + End / 2") from it; there is no C# port yet of the actual +`SkillFormula::Calculate` numeric math (`floor((x*a1 + y*a2 + w)/z + 0.5)`). +CA1 needs to add this — and per the finding below, it should **not** copy +ACE's simplified C# version. + +### ACE cross-check: strong structural match, one formula-fidelity gap + +`references/ACE/Source/ACE.Server/WorldObjects/Entity/CreatureSkill.cs` +(`Current` property) matches the retail decompile's *structure* almost +term-for-term: + +- `IsUsable` gate ≈ retail's `sac < min_level` (ACE additionally special- + cases `min_level==1` meaning "usable while untrained" — consistent). +- `total += InitLevel + Ranks` ≈ retail's `_init_level + _level_from_pp`. +- `GetAugBonus_Base` (LumAugAllSkills flat, AugmentationSkilled{Melee, + Missile,Magic}*10 gated by skill category, **plus `Enlightenment`**) is + added **before** the vitae/enchantment multiplier — same tier as retail's + pre-`EnchantSkill` additions (0x16d, 300/301/302). +- `GetAugBonus_Current` (`AugmentationJackOfAllTrades*5`, + `LumAugSkilledSpec*2` gated on `SAC==Specialized`) is added **after** + vitae/multiplier — same tier as retail's post-`EnchantSkill` additions + (0x146, 0x158). This before/after placement matching retail exactly (down + to which two bonuses are on which side of the vitae multiply) is strong + independent confirmation the decompile trace above is read correctly. +- **Divergence**: ACE's `AugmentationSkilledMelee/Missile/Magic` bonus is + `aug_level * 10`; retail's is a **flat +10 gated on `>0`**, not scaled by + the stored int's magnitude. In practice these augmentations are one-shot + unlocks (0 or 1), so the two are almost certainly equivalent at runtime — + but they are not the same formula, and if the augmentation is ever + stackable this would diverge. Not important enough to block CA1, but do + not copy ACE's `* 10` verbatim without checking the real PropertyInt's + range. +- **Version-era gap, not a bug**: ACE adds an `Enlightenment` bonus + (`AdvancementClass >= Trained && Enlightenment != 0`) that **does not + exist anywhere in the Sept-2013 client's `InqSkill`/`InqSkillBaseLevel`**. + Either Enlightenment postdates this build, or it's applied exclusively + server-side and baked into a field the client already stores (unconfirmed + either way — flagged OPEN). **Do not port an Enlightenment skill bonus + into the client-side live formula unless/until acdream's target retail era + is confirmed to include it; if ported, it needs its own divergence-register + row either way** since our reference build doesn't show it. + +`references/ACE/Source/ACE.Server/Entity/AttributeFormula.cs` — **real +divergence, load-bearing**: + +```csharp +public static uint GetFormula(Creature creature, DatLoader.Entity.SkillFormula formula, bool current = true) +{ + if (formula.X == 0) return 0; + var total = current ? creature.Attributes[attr1].Current : creature.Attributes[attr1].Base; + if (attr2 != Undef) total += current ? creature.Attributes[attr2].Current : ...Base; + if (divisor != 1) total = (uint)((float)total / divisor).Round(); + return total; +} +``` + +This **ignores `_w` (the additive constant) entirely, and ignores `_x`/`_y` +as per-attribute weights** — it just sums the raw attribute values +unweighted (using `_x` only as an on/off gate) then divides. Retail's +`SkillFormula::Calculate` is `floor((_x*a1 + _y*a2 + _w)/_z + 0.5)` — a +strict superset. If the real `portal.dat` SkillTable/`Attribute2ndTable` +records ever have `_w != 0` or `_x != _y` (a genuinely weighted two-attribute +formula, e.g. "mostly Quickness, some Coordination"), ACE's simplified +version silently produces a different number than retail. **CA1 must port +the verbatim retail formula (`floor((x*a1+y*a2+w)/z + 0.5)`), not ACE's +`AttributeFormula.GetFormula` — the verbatim version is safe even in the +common case where `x=y=1,w=0` reduces to the same thing, so there's no +downside to using the more complete formula.** This is exactly the kind of +"verify shared math before substituting" trap the project's own +`feedback_wb_migration_formulas.md` lesson warns about — flag as a MUST- +VERIFY-AGAINST-LIVE-DAT item before shipping (a live `SkillTable`/ +`Attribute2ndTable` dump via `DatCollection` would resolve it in one probe: +grep for any record with non-1 `_x`/`_y` or non-zero `_w`). + +`CreatureVital.cs` (`GetMaxValue`) independently reaches the **same** +structural conclusion the decompile trace did for `GearMaxHealth`/ +`Enlightenment` placement — ACE's own code comment: *"Enlightenment and +GearMaxHealth were an exception, and added in beforehand... this means +[they] would get scaled by multipliers... and vitae as well."* This matches +§3's finding that the client's PropertyInt-0x17b (`GearMaxHealth`) addition +happens **before** the `AttributeCache`/`EnchantAttribute2nd` step — i.e. +before whatever multiplier/vitae scaling lives inside `EnchantAttribute2nd`. +Strong independent cross-confirmation. + +--- + +## 9. Open items + +1. **`ACCWeenieObject::OnStatUpdated` overload resolution** for the raw- + scalar Attribute/Attribute2nd/Skill `UpdateStat` instantiations — which + of the two concrete bodies (`0x0058c680` bool-id switch, `0x0058df20` + int-id switch) actually gets called. Tried: grepped both bodies' switch + cases (neither has an Attribute/Skill-id case, so it's a no-op either + way); tried Ghidra `function_xrefs` on the symbolic name (no results — + binary has no relocation-based xref table entry for the overloaded + name). Non-blocking: confirmed harmless for character-advancement + regardless of which one resolves. +2. **Exact `Attribute2ndTable`/`SkillTable` DAT coefficients** (which skills + have `_attr1==_attr2==0`; whether any real record has `_w!=0` or + `_x!=_y`) — code-level mechanism confirmed (§5, §8), specific numbers are + DAT data and need a live `DatCollection` probe, not decomp. +3. **Whether ACE's server actually pushes a fresh `PrivateUpdateAttribute2nd` + for MaxHealth/MaxStamina/MaxMana when the underlying Endurance/Self + changes** (§3's load-bearing assumption about what drives the vitals bar + redraw in retail). Traced ACE's `CreatureVital`/`AttributeFormula` C# + math but did not trace ACE's *push/dirty* path (`Player_Vitals.cs` / + wherever ACE decides to re-send `GameMessagePrivateUpdateAttribute2nd`) — + out of scope for this decomp-focused pass; recommend a short follow-up + read of `references/ACE/Source/ACE.Server/WorldObjects/Player_Vitals.cs` + and `Creature_Skills.cs`'s raise-skill path before implementing CA1's + server-sync expectations. +4. **Enlightenment's absence from the 2013 client formula** (§8) — need to + confirm which retail era introduced client-visible Enlightenment skill + bonuses, if acdream ever targets that era. From 65430d4c7c8fbe84b95b492d1f3767c11f1b9aca Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 13:38:50 +0200 Subject: [PATCH 17/89] feat(net) Campaign CA CA2 #431: parse the inbound attribute/skill update family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server's authoritative answers to a raise were dropped on the floor: only the vitals pair (0x02E7/0x02E9) had parsers, so after any RaiseAttribute/RaiseSkill/TrainSkill the client's stat model stayed frozen at login's PlayerDescription — the root cause of #431's stale derived skills and run speed. The GUI looked alive only because the panel applies optimistic local raises. New parsers with three-source-verified layouts (CA1 research doc §2.5/ §2.8): PrivateUpdateAttribute (0x02E3) and PrivateUpdateSkill (0x02DD — the wire's ushort ranks + hardcoded adjustPP=1 pair and f64 lastUsedTime preserved exactly). WorldSession dispatches both as typed events; LiveSessionEventRouter routes them into the J4 character owner's LocalPlayerState like every other private update. The vestigial PrivateUpdateSkillLevel (0x02DF) is deliberately unparsed — ACE has no producer (verified). OnAttributeUpdate now fans out to the derived-value observers, mirroring retail's live-at-inquiry model (CACQualities::InqSkill 0x00592660 — Set* writes raw, Inq* recomputes, notification carries no value): an Endurance write notifies the Health AND Stamina vital observers (ACE pushes only a Health record and its own comment says the client must refresh both), Self notifies Mana, and every attribute write notifies character-sheet consumers whose formula contributions just changed. OnSkillWireUpdate preserves the login FormulaBonus — the wire record carries no attribute contribution; CA3 replaces the cached field with the live computation. Also corrected while in the neighborhood: PropertyString.cs's comment claimed opcode 0x02DD for PrivateUpdatePropertyString; ACE's enum says 0x02D5/0x02D6 (doc-only — nothing dispatched on either). Conformance tests cover both layouts (including holtburger's golden skill fixture with adjustPP=1), truncation/wrong-opcode rejection, the Endurance/Self/Quickness fan-out contract, and FormulaBonus preservation. Full hermetic suite 15,333 passed / 0 failed (one load-sensitive transport flake observed on the first run, passed alone and on the clean re-run — filed as #439 rather than chased). Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 20 +++ .../Messages/PrivateUpdateAttribute.cs | 69 +++++++++ .../Messages/PrivateUpdateSkill.cs | 85 ++++++++++++ src/AcDream.Core.Net/WorldSession.cs | 33 +++++ src/AcDream.Core/Player/LocalPlayerState.cs | 61 +++++++- src/AcDream.Core/Properties/PropertyString.cs | 2 +- .../Session/LiveSessionEventRouter.cs | 22 +++ .../PrivateUpdateAttributeSkillTests.cs | 131 ++++++++++++++++++ .../Player/LocalPlayerStateTests.cs | 55 ++++++++ 9 files changed, 474 insertions(+), 4 deletions(-) create mode 100644 src/AcDream.Core.Net/Messages/PrivateUpdateAttribute.cs create mode 100644 src/AcDream.Core.Net/Messages/PrivateUpdateSkill.cs create mode 100644 tests/AcDream.Core.Net.Tests/PrivateUpdateAttributeSkillTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 6e7f66f7..131df0a0 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,26 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #439 — Flake candidate: LossySession_FivePercentSeeded_ZeroMessageLoss_Headroom256 fails under full parallel suite load + +**Status:** OPEN (observation filed; do NOT chase individually per docs/release-gate.md) +**Severity:** LOW (test-infra) +**Filed:** 2026-08-24 (one occurrence during Campaign CA CA2's full-suite run) +**Component:** Core.Net.Tests / transport lossy decorator + +**Symptom:** `LossyTransportDecoratorTests.LossySession_FivePercentSeeded_ZeroMessageLoss_Headroom256` +failed once under the full parallel hermetic suite with `Headroom expected 256, actual 0` +— i.e. the seeded loss-recovery simulation ended before the crypto search +window recovered. Passed in isolation immediately after, and the full +suite passed clean on re-run. The test drives a deadline loop over a +seeded end-to-end loss simulation — the load-sensitive shape +`Lane=Timing` exists for, but this test is not marked. Candidate fix: +either mark it `Lane=Timing` or make its quiescence wait +deadline-independent. Decide deliberately; do not just re-run until +green. + +--- + ## #438 — Launcher crash-report bundles (WER dump capture + local bundle, no upload) **Status:** OPEN — designed, ready to pick up as a launcher slice diff --git a/src/AcDream.Core.Net/Messages/PrivateUpdateAttribute.cs b/src/AcDream.Core.Net/Messages/PrivateUpdateAttribute.cs new file mode 100644 index 00000000..7c61f673 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/PrivateUpdateAttribute.cs @@ -0,0 +1,69 @@ +using System; +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound primary-attribute update GameMessage for the local player +/// (0x02E3) — the server's authoritative answer to a RaiseAttribute +/// action (and any other server-side attribute change). A standalone +/// GameMessage like , NOT a 0xF7B0 +/// GameEvent. +/// +/// +/// Campaign CA slice CA2 (#431): until this parser existed the client's +/// attribute model was stale from login's PlayerDescription until the next +/// login — every post-raise derived value (skills, vitals maxima, run +/// rate) computed from old attributes. +/// +/// +/// +/// Wire layout — three-source agreement (ACE +/// GameMessagePrivateUpdateAttribute.cs:8-16; Chorizite +/// AttributeInfo.generated.cs:26-54; holtburger +/// player/types.rs:22-53 UpdateAttribute<false>), full +/// citations in +/// docs/research/2026-08-24-advancement-wire-and-recompute.md §2.5: +/// +/// +/// PrivateUpdateAttribute (0x02E3): +/// u32 opcode = 0x02E3 +/// u8 sequence // ByteSequence, per-attribute counter +/// u32 attribute // PropertyAttribute (1=Strength..6=Self) +/// u32 ranks +/// u32 start // StartingValue / InitLevel +/// u32 xp // ExperienceSpent / CPSpent +/// +/// +public static class PrivateUpdateAttribute +{ + public const uint Opcode = 0x02E3u; + + /// Parsed attribute update. + public readonly record struct Parsed( + byte Sequence, + uint AttributeId, + uint Ranks, + uint Start, + uint Xp); + + /// + /// Parse a raw PrivateUpdateAttribute (0x02E3) body. Returns + /// null on opcode mismatch or truncation. + /// + public static Parsed? TryParse(ReadOnlySpan body) + { + // 4 (opcode) + 1 (seq) + 4 * 4 (uints) = 21 bytes minimum. + if (body.Length < 21) return null; + uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body); + if (opcode != Opcode) return null; + + int pos = 4; + byte seq = body[pos]; pos += 1; + uint attr = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; + uint ranks = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; + uint start = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; + uint xp = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); + return new Parsed(seq, attr, ranks, start, xp); + } +} diff --git a/src/AcDream.Core.Net/Messages/PrivateUpdateSkill.cs b/src/AcDream.Core.Net/Messages/PrivateUpdateSkill.cs new file mode 100644 index 00000000..0d7f9a9d --- /dev/null +++ b/src/AcDream.Core.Net/Messages/PrivateUpdateSkill.cs @@ -0,0 +1,85 @@ +using System; +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Inbound skill update GameMessage for the local player +/// (0x02DD) — the server's authoritative answer to RaiseSkill / +/// TrainSkill / specialize / untrain / reset. A standalone GameMessage +/// like , NOT a 0xF7B0 GameEvent. +/// +/// +/// Campaign CA slice CA2 (#431). NOTE the opcode-neighborhood trap this +/// slice also corrected: 0x02DD is PrivateUpdateSkill, not +/// PrivateUpdatePropertyString (which is 0x02D5) — ACE +/// GameMessageOpcode.cs:21,29. The related ranks-only +/// PrivateUpdateSkillLevel (0x02DF) has NO producer anywhere in +/// ACE (verified 2026-08-24) and is deliberately not parsed. +/// +/// +/// +/// Wire layout — three-source agreement (ACE +/// GameMessagePrivateUpdateSkill.cs:8-24; Chorizite +/// Skill.generated.cs:22-86; holtburger +/// player/types.rs:71-131 with the golden fixture at +/// :279-293 confirming adjustPP=1 on real captures), full +/// citations in +/// docs/research/2026-08-24-advancement-wire-and-recompute.md §2.8: +/// +/// +/// PrivateUpdateSkill (0x02DD): +/// u32 opcode = 0x02DD +/// u8 sequence // ByteSequence, per-skill counter +/// u32 skillId // Skill enum ordinal +/// u16 ranks // LevelFromPP — ushort on the wire! +/// u16 adjustPP // hardcoded 1 by ACE on every send +/// u32 advancementClass // SkillAdvancementClass (1=Untrained/2=Trained/3=Specialized) +/// u32 xp // ExperienceSpent / PP +/// u32 init // InitLevel +/// u32 resistance // ResistanceAtLastCheck +/// f64 lastUsedTime +/// +/// +public static class PrivateUpdateSkill +{ + public const uint Opcode = 0x02DDu; + + /// Parsed skill update. Ranks widened from the wire's u16. + public readonly record struct Parsed( + byte Sequence, + uint SkillId, + uint Ranks, + ushort AdjustPP, + uint AdvancementClass, + uint Xp, + uint Init, + uint Resistance, + double LastUsed); + + /// + /// Parse a raw PrivateUpdateSkill (0x02DD) body. Returns + /// null on opcode mismatch or truncation. + /// + public static Parsed? TryParse(ReadOnlySpan body) + { + // 4 (opcode) + 1 (seq) + 4 + 2 + 2 + 4*4 + 8 = 37 bytes minimum. + if (body.Length < 37) return null; + uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body); + if (opcode != Opcode) return null; + + int pos = 4; + byte seq = body[pos]; pos += 1; + uint skillId = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; + ushort ranks = BinaryPrimitives.ReadUInt16LittleEndian(body[pos..]); pos += 2; + ushort adjustPP = BinaryPrimitives.ReadUInt16LittleEndian(body[pos..]); pos += 2; + uint sac = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; + uint xp = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; + uint init = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; + uint resistance = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; + double lastUsed = BitConverter.Int64BitsToDouble( + BinaryPrimitives.ReadInt64LittleEndian(body[pos..])); + return new Parsed( + seq, skillId, ranks, adjustPP, sac, xp, init, resistance, lastUsed); + } +} diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 2b83fc50..60d378fe 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -500,6 +500,21 @@ public sealed class WorldSession : IDisposable /// public event Action? VitalCurrentUpdated; + /// + /// Campaign CA CA2 (#431): fires when a + /// PrivateUpdateAttribute (0x02E3) arrives — the authoritative + /// primary-attribute record after a raise. Subscribers typically feed + /// . + /// + public event Action? AttributeUpdated; + + /// + /// Campaign CA CA2 (#431): fires when a + /// PrivateUpdateSkill (0x02DD) arrives — the authoritative skill + /// record after raise / train / specialize / untrain / reset. + /// + public event Action? SkillUpdated; + /// /// Phase 6 — server-broadcast PhysicsScript trigger. Fires when the /// server sends a PlayScriptId (opcode 0xF754) packet — @@ -2257,6 +2272,24 @@ public sealed class WorldSession : IDisposable if (parsed is not null) VitalCurrentUpdated?.Invoke(parsed.Value); } + else if (op == PrivateUpdateAttribute.Opcode) + { + // Campaign CA CA2 (#431): authoritative attribute record + // after RaiseAttribute. Wire per ACE + // GameMessagePrivateUpdateAttribute (3-source agreement). + var parsed = PrivateUpdateAttribute.TryParse(body); + if (parsed is not null) + AttributeUpdated?.Invoke(parsed.Value); + } + else if (op == PrivateUpdateSkill.Opcode) + { + // Campaign CA CA2 (#431): authoritative skill record after + // raise/train/specialize/untrain/reset. Wire per ACE + // GameMessagePrivateUpdateSkill (3-source agreement). + var parsed = PrivateUpdateSkill.TryParse(body); + if (parsed is not null) + SkillUpdated?.Invoke(parsed.Value); + } else if (op == PublicUpdatePropertyInt.Opcode) { var p = PublicUpdatePropertyInt.TryParse(body); diff --git a/src/AcDream.Core/Player/LocalPlayerState.cs b/src/AcDream.Core/Player/LocalPlayerState.cs index b122baf0..d15b6366 100644 --- a/src/AcDream.Core/Player/LocalPlayerState.cs +++ b/src/AcDream.Core/Player/LocalPlayerState.cs @@ -444,15 +444,43 @@ public sealed class LocalPlayerState } /// - /// Apply a primary-attribute update from PlayerDescription's - /// attribute block (ids 1..=6). Vital ids (7..=9) here are silently - /// dropped — feed them through instead. + /// Apply a primary-attribute update — from PlayerDescription's + /// attribute block at login, or (Campaign CA CA2, #431) from the live + /// PrivateUpdateAttribute (0x02E3) record after a raise. Vital + /// ids (7..=9) here are silently dropped — feed them through + /// instead. /// + /// + /// Retail computes every derived value LIVE at inquiry + /// (CACQualities::InqSkill @ 0x00592660, + /// InqAttribute2nd), so applying the raw write is the whole + /// recompute — what remains is telling the observers. Retail's + /// notification carries no value; widgets re-pull + /// (QualityRegistrar pattern). Vitals maxima derive from + /// Endurance (health, stamina) and Self (mana), so those raises fan + /// out to the vital observers too — note ACE pushes a full Health + /// record after an Endurance raise but NOT a Stamina one, with the + /// explicit comment that the client is expected to refresh both; this + /// local fan-out is that expectation (research doc §4.1). + /// public void OnAttributeUpdate(uint atType, uint ranks, uint start, uint xp) { if (AttributeIdToKind(atType) is not AttributeKind kind) return; _attrs[kind] = new AttributeSnapshot(ranks, start, xp); AttributeChanged?.Invoke(kind); + switch (kind) + { + case AttributeKind.Endurance: + Changed?.Invoke(VitalKind.Health); + Changed?.Invoke(VitalKind.Stamina); + break; + case AttributeKind.Self: + Changed?.Invoke(VitalKind.Mana); + break; + } + // Skill formula contributions derive from attribute currents — + // character-sheet consumers re-pull. + CharacterChanged?.Invoke(); } /// Replace the local player's top-level property snapshot from PlayerDescription. @@ -490,6 +518,33 @@ public sealed class LocalPlayerState CharacterChanged?.Invoke(); } + /// + /// Campaign CA CA2 (#431): apply the live + /// PrivateUpdateSkill (0x02DD) record — the authoritative skill + /// state after raise / train / specialize / untrain / reset. The wire + /// record carries no attribute-formula contribution (retail computes it + /// live at inquiry), so the existing snapshot's FormulaBonus is + /// preserved — a skill update never changes attributes. A skill unseen + /// at login (fresh train) starts at 0 until the CA3 live computation + /// replaces the cached field entirely. + /// + public void OnSkillWireUpdate( + uint skillId, + uint ranks, + uint status, + uint xp, + uint init, + uint resistance, + double lastUsed) + { + uint formulaBonus = _skills.TryGetValue(skillId, out var prev) + ? prev.FormulaBonus + : 0u; + _skills[skillId] = new SkillSnapshot( + skillId, ranks, status, xp, init, resistance, lastUsed, formulaBonus); + CharacterChanged?.Invoke(); + } + /// /// Optimistically apply a successful local attribute-raise action. /// The next server snapshot remains authoritative; this keeps UI state current diff --git a/src/AcDream.Core/Properties/PropertyString.cs b/src/AcDream.Core/Properties/PropertyString.cs index 0e2f0b03..d64d5cfc 100644 --- a/src/AcDream.Core/Properties/PropertyString.cs +++ b/src/AcDream.Core/Properties/PropertyString.cs @@ -10,7 +10,7 @@ namespace AcDream.Core.Properties; /// /// AC's PropertyString property table — the numeric keys the server sends in -/// PrivateUpdatePropertyString (0x02DD) / PublicUpdatePropertyString (0x02DE) +/// PrivateUpdatePropertyString (0x02D5) / PublicUpdatePropertyString (0x02D6) /// and in the property bundles carried by CreateObject / PlayerDescription / /// IdentifyResponse. The CLR payload for this table is string. /// diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index f1caafdf..a633e69e 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -460,6 +460,28 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting vital => character.Character.LocalPlayer.OnVitalCurrent( vital.VitalId, vital.Current)); + // Campaign CA CA2 (#431): the authoritative post-raise records. + // Until these were routed, attributes and skills were stale + // from login's PlayerDescription until the next login. + Subscribe( + h => session.AttributeUpdated += h, + h => session.AttributeUpdated -= h, + attr => character.Character.LocalPlayer.OnAttributeUpdate( + attr.AttributeId, + attr.Ranks, + attr.Start, + attr.Xp)); + Subscribe( + h => session.SkillUpdated += h, + h => session.SkillUpdated -= h, + skill => character.Character.LocalPlayer.OnSkillWireUpdate( + skill.SkillId, + skill.Ranks, + skill.AdvancementClass, + skill.Xp, + skill.Init, + skill.Resistance, + skill.LastUsed)); if (Interlocked.CompareExchange(ref _lifecycleState, 2, 1) != 1) throw new ObjectDisposedException(nameof(LiveSessionEventRouter)); diff --git a/tests/AcDream.Core.Net.Tests/PrivateUpdateAttributeSkillTests.cs b/tests/AcDream.Core.Net.Tests/PrivateUpdateAttributeSkillTests.cs new file mode 100644 index 00000000..1b2a014a --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/PrivateUpdateAttributeSkillTests.cs @@ -0,0 +1,131 @@ +using System.Buffers.Binary; +using AcDream.Core.Net.Messages; + +namespace AcDream.Core.Net.Tests; + +/// +/// Campaign CA CA2 (#431) wire-format tests for +/// and +/// . Layouts carry three-source +/// agreement (ACE producer + Chorizite generated type + holtburger +/// implementation); the skill round-trip mirrors holtburger's golden +/// fixture (types.rs:279-293 — ranks 50, adjustPP 1, status 3, +/// xp 1000, init 10, resistance 0, lastUsed 0.0). Full citations: +/// docs/research/2026-08-24-advancement-wire-and-recompute.md +/// §2.5 / §2.8. +/// +public sealed class PrivateUpdateAttributeSkillTests +{ + private static byte[] BuildAttribute( + byte seq, uint attr, uint ranks, uint start, uint xp) + { + // u32 opcode (0x02E3) + u8 seq + 4 * u32 = 21 bytes + byte[] body = new byte[21]; + BinaryPrimitives.WriteUInt32LittleEndian(body, PrivateUpdateAttribute.Opcode); + body[4] = seq; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(5), attr); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(9), ranks); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(13), start); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(17), xp); + return body; + } + + private static byte[] BuildSkill( + byte seq, + uint skillId, + ushort ranks, + ushort adjustPP, + uint sac, + uint xp, + uint init, + uint resistance, + double lastUsed) + { + // u32 opcode (0x02DD) + u8 seq + u32 + 2*u16 + 4*u32 + f64 = 37 bytes + byte[] body = new byte[37]; + BinaryPrimitives.WriteUInt32LittleEndian(body, PrivateUpdateSkill.Opcode); + body[4] = seq; + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(5), skillId); + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(9), ranks); + BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(11), adjustPP); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(13), sac); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(17), xp); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(21), init); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(25), resistance); + BinaryPrimitives.WriteInt64LittleEndian( + body.AsSpan(29), BitConverter.DoubleToInt64Bits(lastUsed)); + return body; + } + + [Fact] + public void Attribute_RoundTrip() + { + // A Quickness (3) raise: 41 ranks over a 100 start, 1,010,895 xp. + var bytes = BuildAttribute(seq: 7, attr: 3, ranks: 41, start: 100, xp: 1_010_895); + + var p = PrivateUpdateAttribute.TryParse(bytes); + + Assert.NotNull(p); + Assert.Equal((byte)7, p!.Value.Sequence); + Assert.Equal(3u, p.Value.AttributeId); + Assert.Equal(41u, p.Value.Ranks); + Assert.Equal(100u, p.Value.Start); + Assert.Equal(1_010_895u, p.Value.Xp); + } + + [Fact] + public void Attribute_RejectsWrongOpcodeAndTruncation() + { + var bytes = BuildAttribute(1, 1, 1, 10, 100); + BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0x02E7u); + Assert.Null(PrivateUpdateAttribute.TryParse(bytes)); + + var good = BuildAttribute(1, 1, 1, 10, 100); + Assert.Null(PrivateUpdateAttribute.TryParse(good.AsSpan(0, 20))); + } + + [Fact] + public void Skill_RoundTrip_HoltburgerGoldenFixture() + { + // holtburger types.rs:279-293 — adjustPP=1 confirmed on a real + // capture, matching ACE's hardcoded constant. + var bytes = BuildSkill( + seq: 12, skillId: 6, ranks: 50, adjustPP: 1, sac: 3, + xp: 1000, init: 10, resistance: 0, lastUsed: 0.0); + + var p = PrivateUpdateSkill.TryParse(bytes); + + Assert.NotNull(p); + Assert.Equal((byte)12, p!.Value.Sequence); + Assert.Equal(6u, p.Value.SkillId); + Assert.Equal(50u, p.Value.Ranks); + Assert.Equal((ushort)1, p.Value.AdjustPP); + Assert.Equal(3u, p.Value.AdvancementClass); + Assert.Equal(1000u, p.Value.Xp); + Assert.Equal(10u, p.Value.Init); + Assert.Equal(0u, p.Value.Resistance); + Assert.Equal(0.0, p.Value.LastUsed); + } + + [Fact] + public void Skill_LastUsedSurvivesAsDoubleBits() + { + var bytes = BuildSkill(1, 14, 3, 1, 2, 42, 0, 5, 12345.678); + + var p = PrivateUpdateSkill.TryParse(bytes); + + Assert.NotNull(p); + Assert.Equal(12345.678, p!.Value.LastUsed); + } + + [Fact] + public void Skill_RejectsWrongOpcodeAndTruncation() + { + var bytes = BuildSkill(1, 6, 1, 1, 2, 0, 0, 0, 0.0); + BinaryPrimitives.WriteUInt32LittleEndian(bytes, 0x02DFu); + Assert.Null(PrivateUpdateSkill.TryParse(bytes)); + + var good = BuildSkill(1, 6, 1, 1, 2, 0, 0, 0, 0.0); + Assert.Null(PrivateUpdateSkill.TryParse(good.AsSpan(0, 36))); + } +} diff --git a/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs b/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs index 476ecf19..468e41ae 100644 --- a/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs +++ b/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs @@ -709,4 +709,59 @@ public sealed class LocalPlayerStateTests spellId, "Test", "War Magic", 0u, 0u, "", 0f, 0, false, false, "", 0, 0, 0u, 0, false, false, true, 0f, 0u, 0u, 0u, 0); + + // ------------------------------------------------------------------ + // Campaign CA CA2 (#431): live post-raise updates. + // ------------------------------------------------------------------ + + [Fact] + public void AttributeUpdateFansOutToDerivedVitalObserversAndCharacterSheet() + { + // Retail pushes a full Health record after an Endurance raise but NOT + // a Stamina one, expecting the client to refresh both (ACE + // Player_Attributes.cs:56-58; research doc §4.1). The fan-out is the + // client-side half of that contract. + var s = new LocalPlayerState(); + var vitalEvents = new List(); + int attributeEvents = 0, characterEvents = 0; + s.Changed += k => vitalEvents.Add(k); + s.AttributeChanged += _ => attributeEvents++; + s.CharacterChanged += () => characterEvents++; + + s.OnAttributeUpdate(atType: 2u /* Endurance */, ranks: 10u, start: 100u, xp: 500u); + Assert.Equal( + [LocalPlayerState.VitalKind.Health, LocalPlayerState.VitalKind.Stamina], + vitalEvents); + Assert.Equal(1, attributeEvents); + Assert.Equal(1, characterEvents); + + vitalEvents.Clear(); + s.OnAttributeUpdate(atType: 6u /* Self */, ranks: 5u, start: 100u, xp: 250u); + Assert.Equal([LocalPlayerState.VitalKind.Mana], vitalEvents); + + vitalEvents.Clear(); + s.OnAttributeUpdate(atType: 3u /* Quickness */, ranks: 1u, start: 100u, xp: 10u); + Assert.Empty(vitalEvents); // no vital derives from Quickness + Assert.Equal(3, attributeEvents); + } + + [Fact] + public void SkillWireUpdatePreservesTheLoginFormulaBonus() + { + // The 0x02DD record carries no attribute contribution (retail + // computes it live at inquiry). Until CA3 makes the computation + // live, the wire update must not wipe the login-derived bonus. + var s = new LocalPlayerState(); + s.OnSkillUpdate(skillId: 6u, ranks: 10u, status: 2u, xp: 100u, + init: 0u, resistance: 0u, lastUsed: 0d, formulaBonus: 120u); + + s.OnSkillWireUpdate(skillId: 6u, ranks: 11u, status: 2u, xp: 2000u, + init: 0u, resistance: 0u, lastUsed: 5.0d); + + var snap = s.Skills[6u]; + Assert.Equal(11u, snap.Ranks); + Assert.Equal(2000u, snap.Xp); + Assert.Equal(120u, snap.FormulaBonus); + Assert.Equal(131u, snap.BaseLevel); // 120 formula + 0 init + 11 ranks + } } From 578189597796663521eaa95c17e1e28a603b5d4f Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 13:46:00 +0200 Subject: [PATCH 18/89] =?UTF-8?q?feat(runtime)=20Campaign=20CA=20CA3=20#43?= =?UTF-8?q?1:=20live=20derived-stat=20recompute=20=E2=80=94=20a=20raise=20?= =?UTF-8?q?is=20visible=20without=20a=20relog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recompute half of #431, on the CA1 verdict that retail computes derived values LIVE at inquiry (Set* writes raw; InqSkillBaseLevel 0x00592140 -> SkillFormula::Calculate 0x00591960 re-derive per call; InqRunRate 0x00592800 runs every motion tick; UI notifications carry no value and widgets re-pull): - LocalPlayerState gains the SkillTable formula resolver — the same delegate shape (and App-side implementation, RetailSkillFormula over the loaded SkillTable) the PlayerDescription path already uses. An attribute write re-derives every skill snapshot's cached formula contribution; recomputing at the only write that changes the inputs yields values identical to retail's compute-on-read at every read. A freshly TRAINED skill unseen at login derives its contribution live instead of defaulting to zero forever. - The router pushes movement-skill totals down the SAME seam PlayerDescription uses (UpdateMovementSkillBase -> vitae/enchantment recompute -> OnSkillsUpdated -> the App stats applier) after an attribute update, and after a skill update for Run (24) / Jump (22) only. This is what turns a Quickness raise into visible run speed mid-session; the server's own movement-packet echo (HandleRunRateUpdate -> ApplyServerRunRate) remains the correcting authority. - Vitals maxima needed no new plumbing: GetMaxApprox reads attribute currents live and the vitals window binds getter lambdas re-read per frame, so CA2's attribute fan-out completes that path. The character panel already subscribes to AttributeChanged/CharacterChanged. Tests: router behavior test drives the real WorldSession events through the real router and asserts the full chain (state write, live 160/2=80 re-derivation, movement push totals, and that a non-movement skill does NOT push); the subscription-count contract now includes the two new events; Core tests cover the fresh-train resolver derivation. Full hermetic suite 15,335 passed / 0 failed. Co-Authored-By: Claude Fable 5 --- ...26-08-24-character-advancement-campaign.md | 4 +- src/AcDream.Core/Player/LocalPlayerState.cs | 84 +++++++++++++++++- .../Session/LiveSessionEventRouter.cs | 77 +++++++++++++---- .../Player/LocalPlayerStateTests.cs | 21 +++++ .../Session/LiveSessionEventRouterTests.cs | 86 +++++++++++++++++++ 5 files changed, 251 insertions(+), 21 deletions(-) diff --git a/docs/plans/2026-08-24-character-advancement-campaign.md b/docs/plans/2026-08-24-character-advancement-campaign.md index e4a28833..cd0fe8bd 100644 --- a/docs/plans/2026-08-24-character-advancement-campaign.md +++ b/docs/plans/2026-08-24-character-advancement-campaign.md @@ -120,7 +120,7 @@ neighborhood). | Slice | Status | Evidence | |---|---|---| | CA1 | COMPLETE 2026-08-24 | docs/research/2026-08-24-advancement-wire-and-recompute.md — six inbound messages pinned byte-for-byte with 3-source agreement; live-at-inquiry recompute verdict verified by hand in Ghidra; RetailSkillFormula already ports 0x00591960 exactly | -| CA2 | — | | -| CA3 | — | | +| CA2 | COMPLETE 2026-08-24 (`65430d4c`) | 0x02E3/0x02DD parsers + WorldSession events + router routing into LocalPlayerState; conformance tests incl. holtburger golden fixture; 0x02DF deliberately unparsed (no ACE producer) | +| CA3 | COMPLETE 2026-08-24 | Live formula recompute (SkillFormulaBonusResolver over RetailSkillFormula) on attribute writes + fresh-train derivation; movement re-applied down the PD seam (PushMovementSkillTotals — Quickness raise → run speed, no relog); vitals bar pull-model verified; router behavior + fresh-train tests | | CA4 | — | | | CA5 | — | | diff --git a/src/AcDream.Core/Player/LocalPlayerState.cs b/src/AcDream.Core/Player/LocalPlayerState.cs index d15b6366..361c2425 100644 --- a/src/AcDream.Core/Player/LocalPlayerState.cs +++ b/src/AcDream.Core/Player/LocalPlayerState.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.Linq; using AcDream.Core.Items; using AcDream.Core.Physics; using AcDream.Core.Properties; @@ -462,11 +464,19 @@ public sealed class LocalPlayerState /// record after an Endurance raise but NOT a Stamina one, with the /// explicit comment that the client is expected to refresh both; this /// local fan-out is that expectation (research doc §4.1). + /// Skill snapshots cache their attribute-formula contribution + /// (), so an attribute write + /// re-derives every cached bonus through + /// before observers re-pull — + /// the compute-on-write equivalent of retail's compute-on-read; the + /// values agree at every read because the ONLY input that changes + /// between reads is exactly what this method writes. /// public void OnAttributeUpdate(uint atType, uint ranks, uint start, uint xp) { if (AttributeIdToKind(atType) is not AttributeKind kind) return; _attrs[kind] = new AttributeSnapshot(ranks, start, xp); + RecomputeSkillFormulaBonuses(); AttributeChanged?.Invoke(kind); switch (kind) { @@ -537,14 +547,82 @@ public sealed class LocalPlayerState uint resistance, double lastUsed) { - uint formulaBonus = _skills.TryGetValue(skillId, out var prev) - ? prev.FormulaBonus - : 0u; + // The wire record carries no attribute contribution (retail computes + // it live at inquiry): re-derive through the resolver when present — + // this also covers a freshly TRAINED skill unseen at login — else + // preserve the login-derived value. + uint formulaBonus = SkillFormulaBonusResolver is { } resolver + ? resolver(skillId, AttributeCurrentsById()) + : _skills.TryGetValue(skillId, out var prev) + ? prev.FormulaBonus + : 0u; _skills[skillId] = new SkillSnapshot( skillId, ranks, status, xp, init, resistance, lastUsed, formulaBonus); CharacterChanged?.Invoke(); } + /// + /// Campaign CA CA3 (#431): the SkillTable attribute-formula resolver — + /// the same delegate shape GameEventWiring uses at + /// PlayerDescription parse (App supplies + /// LiveSkillCreditResolver.Resolve over the loaded SkillTable; + /// headless/no-dat hosts leave it null and keep login-cached bonuses). + /// + public Func /*attrCurrents*/, uint>? + SkillFormulaBonusResolver { get; set; } + + /// + /// Campaign CA CA3 (#431): re-derive every skill snapshot's cached + /// attribute-formula contribution from the current attributes. Retail + /// re-derives at every inquiry (InqSkillBaseLevel @ 0x00592140 → + /// SkillFormula::Calculate @ 0x00591960); recomputing at the + /// only write that changes the inputs yields identical values at every + /// read. No-op without a resolver. + /// + public void RecomputeSkillFormulaBonuses() + { + if (SkillFormulaBonusResolver is not { } resolver || _skills.Count == 0) + return; + IReadOnlyDictionary currents = AttributeCurrentsById(); + foreach (uint skillId in _skills.Keys.ToArray()) + { + SkillSnapshot snap = _skills[skillId]; + uint bonus = resolver(skillId, currents); + if (bonus != snap.FormulaBonus) + _skills[skillId] = snap with { FormulaBonus = bonus }; + } + } + + /// + /// Current attribute values keyed by wire id (1=Strength..6=Self) — the + /// dictionary shape and + /// GameEventWiring's PlayerDescription path share. + /// + public IReadOnlyDictionary AttributeCurrentsById() + { + var currents = new Dictionary(_attrs.Count); + foreach ((AttributeKind kind, AttributeSnapshot snap) in _attrs) + currents[(uint)kind + 1u] = snap.Current; + return currents; + } + + /// + /// Campaign CA CA3 (#431): the movement-skill totals + /// (formulaBonus + init + ranks, ACE Skill ordinals Run=24 / + /// Jump=22) in exactly the shape GameEventWiring computes at + /// PlayerDescription parse — so live raises push the SAME numbers down + /// the SAME movement seam. −1 = skill unknown (keep the previous value). + /// + public (int RunSkill, int JumpSkill) MovementSkillTotals() + { + int run = -1, jump = -1; + if (_skills.TryGetValue(24u, out var runSnap)) + run = (int)(runSnap.FormulaBonus + runSnap.Init + runSnap.Ranks); + if (_skills.TryGetValue(22u, out var jumpSnap)) + jump = (int)(jumpSnap.FormulaBonus + jumpSnap.Init + jumpSnap.Ranks); + return (run, jump); + } + /// /// Optimistically apply a successful local attribute-raise action. /// The next server snapshot remains authoritative; this keeps UI state current diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index a633e69e..d4a15522 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -460,28 +460,48 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting vital => character.Character.LocalPlayer.OnVitalCurrent( vital.VitalId, vital.Current)); - // Campaign CA CA2 (#431): the authoritative post-raise records. - // Until these were routed, attributes and skills were stale - // from login's PlayerDescription until the next login. + // Campaign CA CA2/CA3 (#431): the authoritative post-raise + // records. Until these were routed, attributes and skills were + // stale from login's PlayerDescription until the next login. + // CA3 gives the player state the same SkillTable formula + // resolver the PlayerDescription path uses, so an attribute + // write re-derives every cached formula contribution (retail + // computes live at inquiry — InqSkillBaseLevel @ 0x00592140); + // movement then re-applies through the SAME seam as PD + // (UpdateMovementSkillBase -> vitae/enchant recompute -> + // OnSkillsUpdated -> stats application), which is how a + // Quickness raise becomes visible run speed without a relog. + character.Character.LocalPlayer.SkillFormulaBonusResolver = + character.ResolveSkillFormulaBonus; Subscribe( h => session.AttributeUpdated += h, h => session.AttributeUpdated -= h, - attr => character.Character.LocalPlayer.OnAttributeUpdate( - attr.AttributeId, - attr.Ranks, - attr.Start, - attr.Xp)); + attr => + { + character.Character.LocalPlayer.OnAttributeUpdate( + attr.AttributeId, + attr.Ranks, + attr.Start, + attr.Xp); + PushMovementSkillTotals(character); + }); Subscribe( h => session.SkillUpdated += h, h => session.SkillUpdated -= h, - skill => character.Character.LocalPlayer.OnSkillWireUpdate( - skill.SkillId, - skill.Ranks, - skill.AdvancementClass, - skill.Xp, - skill.Init, - skill.Resistance, - skill.LastUsed)); + skill => + { + character.Character.LocalPlayer.OnSkillWireUpdate( + skill.SkillId, + skill.Ranks, + skill.AdvancementClass, + skill.Xp, + skill.Init, + skill.Resistance, + skill.LastUsed); + // Run=24 / Jump=22 are the only movement inputs. + if (skill.SkillId is 22u or 24u) + PushMovementSkillTotals(character); + }); if (Interlocked.CompareExchange(ref _lifecycleState, 2, 1) != 1) throw new ObjectDisposedException(nameof(LiveSessionEventRouter)); @@ -556,6 +576,31 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting ConstructionCheckpoint(); } + /// + /// Campaign CA CA3 (#431): re-apply the movement-skill bases after a + /// live attribute/skill update, down the SAME chain the + /// PlayerDescription path uses (GameEventWiring's onSkillsUpdated → + /// UpdateMovementSkillBase → vitae/enchantment recompute → + /// OnSkillsUpdated/OnMovementStatsUpdated → the App stats applier). + /// Retail re-inquires run rate every motion tick + /// (CACQualities::InqRunRate @ 0x00592800 from + /// CMotionInterp); re-applying at the only writes that change + /// the inputs is our event-driven equivalent, and the server's own + /// re-broadcast echo (HandleRunRateUpdate → + /// ApplyServerRunRate) remains the correcting authority. + /// + private static void PushMovementSkillTotals( + LiveCharacterSessionBindings character) + { + (int runSkill, int jumpSkill) = + character.Character.LocalPlayer.MovementSkillTotals(); + if (runSkill < 0 && jumpSkill < 0) + return; + character.Character.UpdateMovementSkillBase(runSkill, jumpSkill); + character.OnSkillsUpdated?.Invoke(runSkill, jumpSkill); + character.OnMovementStatsUpdated?.Invoke(); + } + /// /// Campaign P Slice P1 (2026-07-30): retail CACQualities::InqLoad /// equivalent (Strength + augmentation property 0xE6 + EncumbranceVal diff --git a/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs b/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs index 468e41ae..fe187bb9 100644 --- a/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs +++ b/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs @@ -764,4 +764,25 @@ public sealed class LocalPlayerStateTests Assert.Equal(120u, snap.FormulaBonus); Assert.Equal(131u, snap.BaseLevel); // 120 formula + 0 init + 11 ranks } + + [Fact] + public void FreshlyTrainedSkillDerivesItsFormulaBonusThroughTheResolver() + { + // CA3: a skill first seen via the wire (a fresh TrainSkill) was + // never in PlayerDescription, so its attribute contribution must + // come from the live resolver, not default to zero forever. + var s = new LocalPlayerState + { + SkillFormulaBonusResolver = (skillId, attrs) => + skillId == 33u && attrs.TryGetValue(4u, out uint coordination) + ? coordination / 4u + : 0u, + }; + s.OnAttributeUpdate(atType: 4u /* Coordination */, ranks: 0u, start: 80u, xp: 0u); + + s.OnSkillWireUpdate(skillId: 33u, ranks: 0u, status: 2u, xp: 0u, + init: 0u, resistance: 0u, lastUsed: 0d); + + Assert.Equal(20u, s.Skills[33u].FormulaBonus); // 80 / 4 + } } diff --git a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs index c30fd2c0..5b172849 100644 --- a/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/LiveSessionEventRouterTests.cs @@ -779,6 +779,90 @@ public sealed class LiveSessionEventRouterTests router.Dispose(); } + [Fact] + public void AttributeAndSkillUpdates_RouteToPlayerStateAndMovementSeam() + { + // Campaign CA CA2/CA3 (#431): the authoritative post-raise records + // must (a) land in LocalPlayerState, (b) re-derive the cached + // formula contributions through the SAME resolver shape the + // PlayerDescription path uses, and (c) re-apply movement skills + // down the SAME seam (UpdateMovementSkillBase -> OnSkillsUpdated -> + // OnMovementStatsUpdated) — that chain is what turns a Quickness + // raise into visible run speed without a relog. + using var session = NewSession(); + var character = new RuntimeCharacterState(); + var skillsPushed = new List<(int Run, int Jump)>(); + int movementStatsUpdated = 0; + + var router = new LiveSessionEventRouter( + session, + NoOpEntitySink(), + NoOpEnvironmentSink(), + NewInventoryBindings(), + new LiveCharacterSessionBindings( + new CombatState(), + character, + // Fake SkillTable formula: Run (24) derives from Quickness + // (id 3) / 2; everything else contributes nothing. + ResolveSkillFormulaBonus: (skillId, attrs) => + skillId == 24u && attrs.TryGetValue(3u, out uint quickness) + ? quickness / 2u + : 0u, + OnSkillsUpdated: (run, jump) => skillsPushed.Add((run, jump)), + OnConfirmationRequest: null, + OnConfirmationDone: null, + ClientTime: () => 0d, + OnMovementStatsUpdated: () => movementStatsUpdated++), + NewSocialBindings()); + router.Attach(); + + // Login-time skill snapshot: Run trained, formula bonus from the + // pre-raise Quickness current of 100 (100/2 = 50). + character.LocalPlayer.OnAttributeUpdate( + atType: 3u, ranks: 0u, start: 100u, xp: 0u); + character.LocalPlayer.OnSkillUpdate( + skillId: 24u, ranks: 10u, status: 2u, xp: 0u, + init: 5u, resistance: 0u, lastUsed: 0d, formulaBonus: 50u); + skillsPushed.Clear(); + movementStatsUpdated = 0; + + // The server's answer to a Quickness raise: current 100 -> 160. + EventDelegate>( + session, nameof(session.AttributeUpdated)) + .Invoke(new PrivateUpdateAttribute.Parsed( + Sequence: 1, AttributeId: 3u, Ranks: 60u, Start: 100u, Xp: 500u)); + + Assert.Equal(160u, + character.LocalPlayer.GetAttribute( + LocalPlayerState.AttributeKind.Quickness)?.Current); + // Formula contribution re-derived live: 160/2 = 80. + Assert.Equal(80u, character.LocalPlayer.Skills[24u].FormulaBonus); + // Movement re-applied with the new total: 80 formula + 5 init + 10 ranks. + Assert.Equal((95, -1), Assert.Single(skillsPushed)); + Assert.Equal(1, movementStatsUpdated); + + // The server's answer to a Run skill raise: ranks 10 -> 11. + EventDelegate>( + session, nameof(session.SkillUpdated)) + .Invoke(new PrivateUpdateSkill.Parsed( + Sequence: 2, SkillId: 24u, Ranks: 11u, AdjustPP: 1, + AdvancementClass: 2u, Xp: 1000u, Init: 5u, + Resistance: 0u, LastUsed: 0d)); + Assert.Equal((96, -1), skillsPushed[^1]); + Assert.Equal(2, movementStatsUpdated); + + // A non-movement skill update must not push movement. + EventDelegate>( + session, nameof(session.SkillUpdated)) + .Invoke(new PrivateUpdateSkill.Parsed( + Sequence: 3, SkillId: 6u, Ranks: 1u, AdjustPP: 1, + AdvancementClass: 2u, Xp: 0u, Init: 0u, + Resistance: 0u, LastUsed: 0d)); + Assert.Equal(2, movementStatsUpdated); + + router.Dispose(); + } + private static LiveEntitySessionSink NoOpEntitySink() => new( Spawned: _ => { }, Deleted: _ => { }, @@ -964,6 +1048,8 @@ public sealed class LiveSessionEventRouterTests nameof(session.TurbineChatReceived), nameof(session.VitalUpdated), nameof(session.VitalCurrentUpdated), + nameof(session.AttributeUpdated), + nameof(session.SkillUpdated), ]; foreach (string eventName in directEvents) Assert.Equal(multiplier, HandlerCount(session, eventName)); From 08b77e20a9bb078038f2416fb3e74ed345133b7d Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 13:56:31 +0200 Subject: [PATCH 19/89] =?UTF-8?q?feat(ui)=20Campaign=20CA=20CA4=20#431:=20?= =?UTF-8?q?server-authoritative=20raises=20=E2=80=94=20the=20optimistic=20?= =?UTF-8?q?layer=20is=20deleted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retail sends a raise and WAITS: one request in flight, the raise controls ghost, and displayed state changes only when the authoritative quality-change record lands (gmStatManagementUI @ 0x004F03F0 family, pinned in docs/research/2026-07-10-retail-panel-behavior-pseudocode.md §5, whose own conclusion names ApplyLocalRaise as the thing to remove). The optimistic layer predates the inbound parsers — it existed so the panel showed anything at all — and with CA2 delivering server truth it became strictly harmful: against ACE, a wrong TrainSkill cost fails SILENTLY, so the optimistic promote-and-debit could show a trained skill the server refused with nothing to ever correct it. Deleted: CharacterSheetProvider.ApplyLocalRaise + both spend helpers, and LocalPlayerState's six optimistic mutators (ApplyAttributeRaise, ApplyVitalRaise, ApplySkillRaise, ApplySkillTraining, DebitIntProperty, DebitInt64Property) with their tests. Added: the one-in-flight latch in HandleRaiseRequest, CharacterSheet.AwaitingRaise ghosting all raise controls, and gate release on every authoritative quality signal (attribute/character/player-property events unconditionally; vital events only release-and-refresh while a raise is in flight, so regen ticks stay out of the sheet-rebuild path). Panel unmount resets the gate — retail's awaiting flag lives on the panel instance. AP-73 NARROWS rather than retires: retail's release on a rejection that produces NO quality change is statically unverifiable, and ACE sends chat-only (Raise*) or nothing (RaiseSkill/TrainSkill) on failure; until the CA5 live check, a silently-rejected request leaves the controls ghosted until panel reopen — recorded with its observable symptom. Also verified for CA4: the train button sends the DAT-exact TrainedCost (ACE's silent exact-match rule), and there is correctly NO panel specialize send — retail/ACE specialize only via the SkillAlterationDevice item-use + confirmation round-trip, whose client seams (SendConfirmationResponse 0x0275, the 0x028B WeenieErrorWithString chat routing) already exist. Provider tests now pin the retail contract: send-without-mutation, one-in-flight, release-on-record, release-on- unmount, and the regen-tick rebuild guard. Full hermetic suite 15,327 passed / 0 failed. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 2 +- ...26-08-24-character-advancement-campaign.md | 2 +- src/AcDream.App/UI/Layout/CharacterSheet.cs | 6 + .../UI/Layout/CharacterSheetProvider.cs | 158 +++++++++--------- .../UI/Layout/CharacterStatController.cs | 12 +- src/AcDream.Core/Player/LocalPlayerState.cs | 99 ----------- .../UI/Layout/CharacterSheetProviderTests.cs | 78 +++++++-- .../Player/LocalPlayerStateTests.cs | 134 --------------- 8 files changed, 152 insertions(+), 339 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 6874bb33..9738eb3c 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -327,7 +327,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-129 | **NARROWED 2026-07-30 (P4 Opus review fix) — `CanMoveInto`/`IsAllowedIn` are now ported and fed; two narrow gaps remain.** `ObjectInfo.CheckEntryRestrictions` resolves the cell's `RestrictionObj` via `PhysicsEngine.Objects` (a `ClientObjectTable`, acdream's `GetObjectA` equivalent) and evaluates the real owner IID / `HouseRestrictionRecord` (open flag, allegiance monarch, guest table) fed from CreateObject's `HouseOwner`/`HouseRestrictions`/`Monarch` PWD-tail fields and live `House_UpdateRestrictions (0x0248)` refreshes — see `RestrictionObjPrevalenceInspectionTests` (103,766 of 729,888 installed EnvCells, 1,293 landblocks, carry a baked `RestrictionObj`; this is the whole housing estate, not a rare case, which is why the OLD unconditional-fail-closed row was upgraded to FIX-FIRST rather than shipped). Remaining gaps: (1) `House_UpdateRestrictions`'s `Sequence` byte is parsed but not used for staleness/reordering rejection — a lost-then-late UDP delivery could transiently apply an older restriction snapshot over a newer one (low-probability; the next full CreateObject or another update self-corrects). (2) Outdoor `CLandCell` restriction (`LandblockInfo.RestrictionTables`, a separate per-landblock packed hash table) remains entirely unported — unaffected by this fix, since the gate only reads the indoor/EnvCell `CellPhysics.RestrictionObj` field. `HouseData (0x0225)`/`HouseStatus (0x0226)` and the guest-management opcode family (`House_AddPermanentGuest`, `House_UpdateHAR`, etc.) remain unparsed but are NOT consulted by this entry gate (they carry rent/ownership-transfer UI data, not the owner-iid/guest-list pair `CanMoveInto` needs) — noted for future house-UI work, not a residual of this row. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`ObjectInfo.CheckEntryRestrictions`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`Objects`); `src/AcDream.Core/Items/{ClientObject,ClientObjectTable,HouseRestrictions}.cs`; `src/AcDream.Core.Net/{Messages/CreateObject.cs,Messages/GameEvents.cs,GameEventWiring.cs}`; `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (production wiring) | A reordered `House_UpdateRestrictions` pair could transiently apply the older snapshot; self-corrects on the next update or CreateObject. An outdoor restricted cell (if that content ever exists) is not gated at all. | `ACCWeenieObject::CanMoveInto` 0x0058da40 (pc:407982-408056); `RestrictionDB::IsAllowedIn` 0x005ae8f0 (pc:444493-444516); `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/RestrictionDB.generated.cs`; `references/ACE/Source/ACE.Server/Network/GameEvent/Events/GameEventHouseUpdateRestrictions.cs` | | AP-72 | **Cursor art falls back to OS standard cursors when dat resolution fails** — retail always renders MediaDescCursor / EnumIDMap-resolved dat cursor art; acdream's `RetailCursorManager.Apply` falls back to Silk `StandardCursor` (IBeam/crosshair/not-allowed/…) when the EnumIDMap chain or RenderSurface decode fails, and `RetailCursorResolver`/`RetailCursorManager` permanently negative-cache the failed enum/surface id for the session. | `src/AcDream.App/Rendering/RetailCursorManager.cs:47` (`ApplyStandard`), `RetailCursorResolver.cs:47` (negative cache) | Fallback triggers only when the dat lacks the asset — nominal EoR dats always resolve the 0x27/0x28/0x29 chain; an OS cursor keeps the UI usable rather than showing nothing. | A dat-read or decode regression silently shows OS-native cursors instead of surfacing an error — masked failure class; check the `[D.2b]` cursor log lines before suspecting art. | `ClientUISystem::UpdateCursorState` 0x00564630 | | AP-74 | **UseDone WeenieError text comes from a hardcoded subset map, not the portal String tables** — retail resolves the 0x01C7 UseDone error code through the client String tables into the canonical line ("You are not trained in healing!"); acdream's `WeenieErrorText.For` hardcodes the handful of codes the current use/heal flows produce (0x001D/0x04EB/0x04FC/0x04FE, texts phrased after the ACE enum names) with a generic code-carrying fallback. | `src/AcDream.Core.Net/Messages/WeenieErrorText.cs` | Every refusal is now visible; only unmapped wording deviates, and those lines retain the raw code. Retire by porting the String-table lookup (#202). | An unmapped WeenieError shows a generic line instead of retail's exact sentence | retail String-table error lookup; ACE `WeenieError.cs` values | -| AP-73 | **Character raises mutate optimistically, contrary to retail's server-authoritative flow** — after sending RaiseAttribute/RaiseVital/RaiseSkill/TrainSkill, `CharacterSheetProvider.ApplyLocalRaise` immediately bumps ranks and debits XP/credits. Named retail permits one request in flight, ghosts the clicked button, and waits for an authoritative quality-change element message before changing displayed state (**#199**). | `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` | ACE usually accepts client-affordable raises, so its later property echoes conceal the incorrect prediction; Wave 8 removes local mutation and owns one awaiting request | A rejected/reordered raise can display invented state until a later full refresh, and repeated clicks can create multiple speculative spends | `gmAttributeUI`/`gmSkillUI` raise and quality-change paths, pinned in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` | +| AP-73 | **NARROWED 2026-08-24 (Campaign CA CA4)** — the optimistic mutation this row filed is DELETED: `ApplyLocalRaise` and the six LocalPlayerState optimistic apply/debit methods are gone, and the raise flow now matches the pinned retail mechanism (one request in flight, raise controls ghost while awaiting, displayed state changes only when the authoritative quality records land — which CA2's inbound 0x02E3/0x02DD parsers now deliver; release on any quality-change event mirrors `gmStatManagementUI::ListenToElementMessage @ 0x004EFBE0`). REMAINING OPEN POINT (the reason this row narrows instead of retiring): retail's release behavior on a rejection that produces NO quality change is statically unverifiable (the pseudocode doc's own §5 caveat), and ACE sends chat-only for a failed Raise* and NOTHING for a rejected RaiseSkill/TrainSkill — acdream holds the gate until the panel remounts (retail's per-instance flag lifetime), which may differ from retail's live behavior. Verify at the CA5 connected gate (deliberately provoke the vital raise-10-with-1-affordable client bug ACE's own comment documents). | `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`HandleRaiseRequest`/`ReleaseAwaitingRaise`); `src/AcDream.App/UI/Layout/CharacterStatController.cs` (AwaitingRaise ghosting) | A silently-rejected request leaves the raise controls ghosted until the panel is reopened | Raise buttons stuck ghosted after a failed raise until panel close/reopen — visible only on server-rejected requests | `gmStatManagementUI @ 0x004F03F0`; `gmAttributeUI::RaiseSelection @ 0x0049D020`; `gmSkillUI::RaiseSelection @ 0x0049C8C0`; `InfoRegion::OnQualityChanged @ 0x004F0EB0`; `ListenToElementMessage @ 0x004EFBE0`; pinned in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` §5 | --- | AP-75 | **NARROWED 2026-07-19 — adapter-boundary `adjust_motion` only.** `SetCycle` remaps TurnLeft/SideStepLeft/WalkBackward to their mirror command with negated speed before dispatch. Retail performs that normalization in `CMotionInterp`; GameWindow's local-player adapter can still pass raw ids directly | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap) | Preserves raw local callers until every caller enters through `MotionInterpreter`; literal DAT velocity and omega now flow through CSequence's complete Frame | A future caller that already normalizes a raw left/back command but still passes the original id can be adjusted twice | `CMotionInterp::adjust_motion` @305343; retire with the remaining local caller unification | diff --git a/docs/plans/2026-08-24-character-advancement-campaign.md b/docs/plans/2026-08-24-character-advancement-campaign.md index cd0fe8bd..47c730d0 100644 --- a/docs/plans/2026-08-24-character-advancement-campaign.md +++ b/docs/plans/2026-08-24-character-advancement-campaign.md @@ -122,5 +122,5 @@ neighborhood). | CA1 | COMPLETE 2026-08-24 | docs/research/2026-08-24-advancement-wire-and-recompute.md — six inbound messages pinned byte-for-byte with 3-source agreement; live-at-inquiry recompute verdict verified by hand in Ghidra; RetailSkillFormula already ports 0x00591960 exactly | | CA2 | COMPLETE 2026-08-24 (`65430d4c`) | 0x02E3/0x02DD parsers + WorldSession events + router routing into LocalPlayerState; conformance tests incl. holtburger golden fixture; 0x02DF deliberately unparsed (no ACE producer) | | CA3 | COMPLETE 2026-08-24 | Live formula recompute (SkillFormulaBonusResolver over RetailSkillFormula) on attribute writes + fresh-train derivation; movement re-applied down the PD seam (PushMovementSkillTotals — Quickness raise → run speed, no relog); vitals bar pull-model verified; router behavior + fresh-train tests | -| CA4 | — | | +| CA4 | COMPLETE 2026-08-24 | Optimistic ApplyLocalRaise layer DELETED; retail one-in-flight + ghost + server-authoritative flow ported per the pinned §5 pseudocode (AP-73 NARROWED — rejection-release semantics owed to CA5 live); train cost verified DAT-exact; specialize correctly has no panel send (gem + confirmation route, seams already present); provider contract tests rewritten | | CA5 | — | | diff --git a/src/AcDream.App/UI/Layout/CharacterSheet.cs b/src/AcDream.App/UI/Layout/CharacterSheet.cs index f0d0f1f3..43aece82 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheet.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheet.cs @@ -142,6 +142,12 @@ public sealed class CharacterSheet /// public int SkillCredits { get; init; } + /// Campaign CA CA4 (retired AP-73): true while a raise/train + /// request is awaiting its authoritative server record — retail permits + /// one in flight and ghosts the raise controls until the quality-change + /// message lands (gmStatManagementUI's awaiting flag). + public bool AwaitingRaise { get; init; } + /// /// Unassigned (banked) experience points. /// Retail InqInt64(2) — shown in footer line-2 in State-A display. diff --git a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs index dc2ecb2a..5d73dba4 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs @@ -10,22 +10,21 @@ namespace AcDream.App.UI.Layout; /// /// Assembles the live for the retail Character -/// window and owns the raise-request flow (wire send + optimistic local -/// apply). Extracted from GameWindow (Code Structure Rule 1: sheet -/// assembly + XP-curve math is feature logic, not wiring). +/// window and owns the raise-request flow. Extracted from +/// GameWindow (Code Structure Rule 1: sheet assembly + XP-curve math +/// is feature logic, not wiring). /// /// Retail property ids and the decomp anchors for every field are /// documented on ; the raise-cost formulas are /// cited there too (gmAttributeUI::GetCostToRaise 0x0049cb80 family). /// -/// State ownership: optimistic debits go through the owning -/// store's eventful APIs — / -/// (fires ObjectUpdated) -/// when the player object is in the table, else -/// / -/// (fires CharacterChanged). -/// Never write the raw property dictionaries from UI code. The next server -/// snapshot remains authoritative over every optimistic value. +/// Raise flow (Campaign CA CA4, retired AP-73): +/// server-authoritative, exactly retail's +/// (gmStatManagementUI — one request in flight, controls ghost while +/// awaiting, no local mutation). Displayed state changes only when the +/// authoritative quality records land (PrivateUpdateAttribute / +/// PrivateUpdateVital / PrivateUpdateSkill plus the XP/credit property +/// updates), which also release the in-flight gate. /// public sealed class CharacterSheetProvider { @@ -177,6 +176,7 @@ public sealed class CharacterSheetProvider : null, Deaths = props.GetInt(0x2Bu), SkillCredits = skillCredits, + AwaitingRaise = _awaitingRaise, UnassignedXp = unassignedXp, AttributeRaiseCosts = BuildAttributeRaiseCosts(amount: 1), AttributeRaise10Costs = BuildAttributeRaiseCosts(amount: 10), @@ -233,6 +233,10 @@ public sealed class CharacterSheetProvider owner._objects.Cleared += OnCleared; owner._localPlayer.AttributeChanged += OnAttributeChanged; owner._localPlayer.CharacterChanged += OnCharacterChanged; + // CA4: vital events participate ONLY in the raise-gate release + // (see OnVitalChanged) — regen ticks fire this constantly and + // must not rebuild the sheet outside an awaited raise. + owner._localPlayer.Changed += OnVitalChanged; // Issue #267: skills/attributes are now vitae + buff aware, so the // sheet must refresh whenever the active-enchantment set changes // (vitae applied/removed on death/lifestone, a buff cast/expired), @@ -245,15 +249,41 @@ public sealed class CharacterSheetProvider { CharacterSheetProvider? owner = _owner; if (owner is not null && value.ObjectId == owner._playerGuid()) + { + // An authoritative player-property record (XP, credits, …) + // is a quality change — retail releases the raise gate on + // any of them (ListenToElementMessage @ 0x004EFBE0). + owner.ReleaseAwaitingRaise(); _changed(); + } } private void OnCleared() => _changed(); - private void OnAttributeChanged(LocalPlayerState.AttributeKind _) => + private void OnAttributeChanged(LocalPlayerState.AttributeKind _) + { + _owner?.ReleaseAwaitingRaise(); _changed(); + } - private void OnCharacterChanged() => _changed(); + private void OnCharacterChanged() + { + _owner?.ReleaseAwaitingRaise(); + _changed(); + } + + private void OnVitalChanged(LocalPlayerState.VitalKind _) + { + // Release-and-refresh only while a raise is in flight: the full + // vital record answering a RaiseVital (or the Endurance/Self + // side-push) must un-ghost the controls, but ordinary regen + // ticks outside a raise stay out of the sheet-rebuild path. + CharacterSheetProvider? owner = _owner; + if (owner is null || !owner._awaitingRaise) + return; + owner.ReleaseAwaitingRaise(); + _changed(); + } public void Dispose() { @@ -267,8 +297,12 @@ public sealed class CharacterSheetProvider owner._objects.Cleared -= OnCleared; owner._localPlayer.AttributeChanged -= OnAttributeChanged; owner._localPlayer.CharacterChanged -= OnCharacterChanged; + owner._localPlayer.Changed -= OnVitalChanged; if (owner._localPlayer.Spellbook is { } spellbook) spellbook.EnchantmentsChanged -= OnCleared; + // Panel unmount resets the one-in-flight raise gate — retail's + // awaiting flag lives on the panel instance and dies with it. + owner.ReleaseAwaitingRaise(); } } @@ -497,14 +531,22 @@ public sealed class CharacterSheetProvider // ── Raise-request flow ───────────────────────────────────────────────── /// - /// Send a raise/train action to the server and, when a send delegate - /// fired, optimistically apply the local effect so the sheet stays - /// current during the round trip. The next server snapshot remains - /// authoritative (a rejected raise is corrected by the property echo — - /// pending/rollback ledger tracked as a follow-up issue). + /// Campaign CA CA4 (#431, retires AP-73): send a raise/train action and + /// wait for the server's authoritative record — retail's exact flow + /// (gmAttributeUI::RaiseSelection @ 0x0049D020 / + /// gmSkillUI::RaiseSelection @ 0x0049C8C0, pinned in + /// docs/research/2026-07-10-retail-panel-behavior-pseudocode.md §5): + /// one request in flight, the raise controls ghost while awaiting, and + /// NO local mutation of ranks/XP/credits — displayed state changes only + /// when the quality-change record lands (which CA2's inbound parsers + /// now deliver). The former optimistic ApplyLocalRaise layer is + /// deleted: against ACE, a wrong TrainSkill cost fails SILENTLY, so an + /// optimistic apply could show a trained skill the server refused with + /// nothing to ever correct it. /// public void HandleRaiseRequest(CharacterStatController.RaiseRequest request) { + if (_awaitingRaise) return; if (request.Cost <= 0) return; if (_canSendRaise is not null && !_canSendRaise()) return; @@ -542,71 +584,25 @@ public sealed class CharacterSheetProvider } if (sent) - ApplyLocalRaise(request); + _awaitingRaise = true; } - private void ApplyLocalRaise(CharacterStatController.RaiseRequest request) - { - uint amount = request.Amount <= 0 ? 1u : (uint)request.Amount; - ulong cost = (ulong)request.Cost; + /// + /// Retail releases the one-in-flight raise gate on ANY authoritative + /// quality-change element message + /// (gmStatManagementUI::ListenToElementMessage @ 0x004EFBE0) — + /// the LocalPlayerState change events are our equivalent. Rejections + /// that produce NO quality change (ACE sends chat-only for a failed + /// Raise*, and nothing at all for a rejected RaiseSkill/TrainSkill) + /// leave the gate held exactly as the static retail evidence leaves it + /// unverified (the pseudocode doc's own caveat); panel remount resets + /// it, matching retail's per-instance field lifetime. Verify live at + /// CA5 before hardening further — see the narrowed AP-73 row. + /// + internal void ReleaseAwaitingRaise() => _awaitingRaise = false; - switch (request.Kind) - { - case CharacterStatController.RaiseTargetKind.Attribute: - if (_localPlayer.ApplyAttributeRaise(request.StatId, amount, cost)) - SpendUnassignedExperience(request.Cost); - break; - case CharacterStatController.RaiseTargetKind.Vital: - if (_localPlayer.ApplyVitalRaise(request.StatId, amount, cost)) - SpendUnassignedExperience(request.Cost); - break; - case CharacterStatController.RaiseTargetKind.Skill: - if (_localPlayer.ApplySkillRaise(request.StatId, amount, cost)) - SpendUnassignedExperience(request.Cost); - break; - case CharacterStatController.RaiseTargetKind.TrainSkill: - if (_localPlayer.ApplySkillTraining(request.StatId)) - SpendSkillCredits(request.Cost); - break; - } - } + /// One raise/train request in flight — retail permits exactly + /// one (gmStatManagementUI's awaiting flag). + private bool _awaitingRaise; - private void SpendUnassignedExperience(long cost) - { - if (cost <= 0) return; - uint guid = _playerGuid(); - if (guid != 0u && _objects.Get(guid) is { } player) - { - long current = player.Properties.GetInt64(UnassignedXpPropertyId); - if (current <= 0) return; - _objects.UpdateInt64Property(guid, UnassignedXpPropertyId, - current > cost ? current - cost : 0L); - return; - } - _localPlayer.DebitInt64Property(UnassignedXpPropertyId, cost); - } - - private void SpendSkillCredits(long cost) - { - if (cost <= 0) return; - int debit = cost > int.MaxValue ? int.MaxValue : (int)cost; - uint guid = _playerGuid(); - if (guid != 0u && _objects.Get(guid) is { } player) - { - foreach (uint propertyId in SkillCreditPropertyIds) - { - if (!player.Properties.Ints.TryGetValue(propertyId, out int current)) - continue; - _objects.UpdateIntProperty(guid, propertyId, - current > debit ? current - debit : 0); - return; - } - return; - } - foreach (uint propertyId in SkillCreditPropertyIds) - { - if (_localPlayer.DebitIntProperty(propertyId, debit)) - return; - } - } } diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index 3f0f232e..d7d430d8 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -1112,8 +1112,10 @@ public static class CharacterStatController var sheet = data(); long cost1 = GetRaiseCost(sheet, selectedIndex, amount: 1); long cost10 = GetRaiseCost(sheet, selectedIndex, amount: 10); - bool affordable1 = cost1 > 0 && sheet.UnassignedXp >= cost1; - bool affordable10 = cost10 > 0 && sheet.UnassignedXp >= cost10; + // CA4 (retired AP-73): while a raise awaits its authoritative + // record, retail ghosts the raise controls (one request in flight). + bool affordable1 = !sheet.AwaitingRaise && cost1 > 0 && sheet.UnassignedXp >= cost1; + bool affordable10 = !sheet.AwaitingRaise && cost10 > 0 && sheet.UnassignedXp >= cost10; foreach (var b in allRaise1) { @@ -1146,9 +1148,9 @@ public static class CharacterStatController bool trained = selectedSkill.AdvancementClass >= CharacterSkillAdvancementClass.Trained; long cost = trained ? selectedSkill.RaiseCost : selectedSkill.TrainedCost; - bool affordable = trained + bool affordable = !sheet.AwaitingRaise && (trained ? cost > 0 && sheet.UnassignedXp >= cost - : cost > 0 && sheet.SkillCredits >= cost; + : cost > 0 && sheet.SkillCredits >= cost); foreach (var b in allRaise1) { b.Visible = true; @@ -1163,7 +1165,7 @@ public static class CharacterStatController if (trained) { long cost10 = selectedSkill.Raise10Cost; - bool affordable10 = cost10 > 0 && sheet.UnassignedXp >= cost10; + bool affordable10 = !sheet.AwaitingRaise && cost10 > 0 && sheet.UnassignedXp >= cost10; b.TrySetRetailState(affordable10 ? UiButtonStateMachine.Normal : UiButtonStateMachine.Ghosted); diff --git a/src/AcDream.Core/Player/LocalPlayerState.cs b/src/AcDream.Core/Player/LocalPlayerState.cs index 361c2425..c0fd1d09 100644 --- a/src/AcDream.Core/Player/LocalPlayerState.cs +++ b/src/AcDream.Core/Player/LocalPlayerState.cs @@ -623,105 +623,6 @@ public sealed class LocalPlayerState return (run, jump); } - /// - /// Optimistically apply a successful local attribute-raise action. - /// The next server snapshot remains authoritative; this keeps UI state current - /// during the round trip after sending the retail raise action. - /// - public bool ApplyAttributeRaise(uint atType, uint amount, ulong xpSpent) - { - if (AttributeIdToKind(atType) is not AttributeKind kind) return false; - if (!_attrs.TryGetValue(kind, out var prev)) return false; - - _attrs[kind] = prev with - { - Ranks = SaturatingAdd(prev.Ranks, amount), - Xp = SaturatingAdd(prev.Xp, xpSpent), - }; - AttributeChanged?.Invoke(kind); - return true; - } - - /// Optimistically apply a successful local max-vital raise action. - public bool ApplyVitalRaise(uint vitalId, uint amount, ulong xpSpent) - { - if (VitalIdToKind(vitalId) is not VitalKind kind) return false; - VitalSnapshot? existing = Get(kind); - if (existing is not VitalSnapshot prev) return false; - - var snap = prev with - { - Ranks = SaturatingAdd(prev.Ranks, amount), - Xp = SaturatingAdd(prev.Xp, xpSpent), - }; - switch (kind) - { - case VitalKind.Health: _health = snap; break; - case VitalKind.Stamina: _stamina = snap; break; - case VitalKind.Mana: _mana = snap; break; - } - Changed?.Invoke(kind); - return true; - } - - /// Optimistically promote an untrained skill after a successful TrainSkill action. - public bool ApplySkillTraining(uint skillId) - { - if (!_skills.TryGetValue(skillId, out var prev)) return false; - if (prev.Status >= 2u) return false; - - _skills[skillId] = prev with { Status = 2u }; - CharacterChanged?.Invoke(); - return true; - } - - /// Optimistically apply a successful local skill-raise action. - public bool ApplySkillRaise(uint skillId, uint amount, ulong xpSpent) - { - if (!_skills.TryGetValue(skillId, out var prev)) return false; - if (prev.Status < 2u) return false; - - _skills[skillId] = prev with - { - Ranks = SaturatingAdd(prev.Ranks, amount), - Xp = SaturatingAdd(prev.Xp, xpSpent), - }; - CharacterChanged?.Invoke(); - return true; - } - - /// - /// Optimistically debit an int property in the player's property bundle - /// (clamped at 0), firing so bound UI - /// refreshes. False if the property is absent — callers walk their - /// fallback id chain. The next server snapshot remains authoritative. - /// All local-player property writes go through eventful APIs like this - /// one; writing dictionaries directly skips the - /// change event and is a single-owner-state violation. - /// - public bool DebitIntProperty(uint propertyId, int amount) - { - if (!_properties.Ints.TryGetValue(propertyId, out int current)) - return false; - _properties.Ints[propertyId] = current > amount ? current - amount : 0; - CharacterChanged?.Invoke(); - return true; - } - - /// - /// Optimistically debit an int64 property (clamped at 0), firing - /// . False if the property is absent or - /// already ≤ 0. The next server snapshot remains authoritative. - /// - public bool DebitInt64Property(uint propertyId, long amount) - { - if (!_properties.Int64s.TryGetValue(propertyId, out long current) || current <= 0) - return false; - _properties.Int64s[propertyId] = current > amount ? current - amount : 0L; - CharacterChanged?.Invoke(); - return true; - } - /// /// Return the character snapshot to its pre-login state. The object itself /// is process-lived because UI view models subscribe to it once; session diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs index ad99c683..1a8a42d7 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs @@ -142,7 +142,7 @@ public sealed class CharacterSheetProviderTests } [Fact] - public void HandleRaiseRequest_Attribute_SendsAndDebitsThroughTableEvents() + public void HandleRaiseRequest_Attribute_SendsWithoutMutation_AndLatchesOneInFlight() { var h = new Harness(); h.AddPlayerObject(unassignedXp: 1000L); @@ -153,11 +153,21 @@ public sealed class CharacterSheetProviderTests h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest( CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 20L, Amount: 1)); + // CA4 (retired AP-73): retail sends and WAITS — no local mutation of + // ranks or XP; displayed state changes only when the authoritative + // record lands (gmStatManagementUI, pseudocode doc §5). Assert.Equal((1u, 20ul), h.SentAttribute); var strength = h.Player.GetAttribute(LocalPlayerState.AttributeKind.Strength); - Assert.Equal(2u, strength!.Value.Ranks); // optimistic rank apply - Assert.Equal(980L, h.Table.Get(PlayerGuid)!.Properties.GetInt64(2u)); // XP debited - Assert.True(tableUpdates >= 1); // via the eventful API + Assert.Equal(1u, strength!.Value.Ranks); // unchanged + Assert.Equal(1000L, h.Table.Get(PlayerGuid)!.Properties.GetInt64(2u)); // undebited + Assert.Equal(0, tableUpdates); + Assert.True(h.Provider.BuildSheet().AwaitingRaise); + + // One request in flight: a second click sends nothing. + h.SentAttribute = null; + h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest( + CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 20L, Amount: 1)); + Assert.Null(h.SentAttribute); } [Fact] @@ -177,7 +187,7 @@ public sealed class CharacterSheetProviderTests } [Fact] - public void HandleRaiseRequest_TrainSkill_DebitsRetailSkillCreditProperty() + public void HandleRaiseRequest_TrainSkill_SendsExactDatCostWithoutMutation() { var h = new Harness(); var player = h.AddPlayerObject(); @@ -189,26 +199,58 @@ public sealed class CharacterSheetProviderTests CharacterStatController.RaiseTargetKind.TrainSkill, StatId: 6u, Cost: 4L, Amount: 1)); Assert.Equal((6u, 4u), h.SentTrain); - Assert.Equal(2u, h.Player.GetSkill(6u)!.Value.Status); // promoted to trained - Assert.Equal(0, player.Properties.GetInt(0x18u)); // credits debited + // CA4: no optimistic promotion or credit debit — against ACE a wrong + // TrainSkill cost fails SILENTLY, so an optimistic apply could show + // a trained skill the server refused, forever. + Assert.Equal(1u, h.Player.GetSkill(6u)!.Value.Status); // still untrained + Assert.Equal(4, player.Properties.GetInt(0x18u)); // credits intact + Assert.True(h.Provider.BuildSheet().AwaitingRaise); } [Fact] - public void SpendUnassignedXp_FallsBackToLocalPlayer_WhenPlayerObjectAbsent() + public void AwaitingRaise_ReleasesOnTheAuthoritativeRecord_AndOnPanelUnmount() { - var h = new Harness(); // note: nothing added to the table - var props = new PropertyBundle(); - props.Int64s[2u] = 500L; - h.Player.OnProperties(props); + // Retail releases the one-in-flight gate on ANY quality-change + // message (ListenToElementMessage @ 0x004EFBE0); the CA2 inbound + // records arriving at LocalPlayerState are our equivalent. The gate + // also dies with the panel binding, matching retail's per-instance + // awaiting flag. + var h = new Harness(); + h.AddPlayerObject(unassignedXp: 1000L); h.Player.OnAttributeUpdate(atType: 1u, ranks: 1u, start: 10u, xp: 10u); - int changed = 0; - h.Player.CharacterChanged += () => changed++; + int rebuilds = 0; + using (h.Provider.SubscribeChanged(() => rebuilds++)) + { + h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest( + CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 20L, Amount: 1)); + Assert.True(h.Provider.BuildSheet().AwaitingRaise); - h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest( - CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 100L, Amount: 1)); + // The authoritative attribute record releases + refreshes. + h.Player.OnAttributeUpdate(atType: 1u, ranks: 2u, start: 10u, xp: 30u); + Assert.False(h.Provider.BuildSheet().AwaitingRaise); + Assert.True(rebuilds >= 1); - Assert.Equal(400L, h.Player.Properties.GetInt64(2u)); // debited on the LPS side - Assert.True(changed >= 1); // and CharacterChanged fired + // A vital regen tick outside a raise must NOT rebuild the sheet. + int before = rebuilds; + h.Player.OnVitalCurrent(vitalId: 2u, current: 50u); + Assert.Equal(before, rebuilds); + + // But the full vital record answering a RaiseVital releases. + h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest( + CharacterStatController.RaiseTargetKind.Vital, StatId: 1u, Cost: 20L, Amount: 1)); + Assert.True(h.Provider.BuildSheet().AwaitingRaise); + h.Player.OnVitalUpdate(vitalId: 1u, ranks: 1u, start: 10u, xp: 20u, current: 15u); + Assert.False(h.Provider.BuildSheet().AwaitingRaise); + } + + // Panel unmount resets a still-held gate (silent-rejection recovery). + using (h.Provider.SubscribeChanged(() => { })) + { + h.Provider.HandleRaiseRequest(new CharacterStatController.RaiseRequest( + CharacterStatController.RaiseTargetKind.Attribute, StatId: 1u, Cost: 20L, Amount: 1)); + Assert.True(h.Provider.BuildSheet().AwaitingRaise); + } + Assert.False(h.Provider.BuildSheet().AwaitingRaise); } // ── Issue #267 — vitae/buff-aware skill + attribute values ─────────────── diff --git a/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs b/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs index fe187bb9..974b816a 100644 --- a/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs +++ b/tests/AcDream.Core.Tests/Player/LocalPlayerStateTests.cs @@ -422,140 +422,6 @@ public sealed class LocalPlayerStateTests Assert.Equal(1, changed); } - [Fact] - public void ApplySkillTraining_PromotesUntrainedSkillAndFiresCharacterChanged() - { - var s = new LocalPlayerState(); - int changed = 0; - s.CharacterChanged += () => changed++; - s.OnSkillUpdate( - skillId: 21u, - ranks: 0u, - status: 1u, - xp: 0u, - init: 5u, - resistance: 0u, - lastUsed: 0, - formulaBonus: 10u); - changed = 0; - - Assert.True(s.ApplySkillTraining(21u)); - - var skill = s.GetSkill(21u); - Assert.NotNull(skill); - Assert.Equal(2u, skill!.Value.Status); - Assert.Equal(1, changed); - } - - [Fact] - public void ApplySkillRaise_AddsRanksAndSpentXp() - { - var s = new LocalPlayerState(); - s.OnSkillUpdate( - skillId: 24u, - ranks: 12u, - status: 2u, - xp: 3456u, - init: 30u, - resistance: 0u, - lastUsed: 0, - formulaBonus: 80u); - - Assert.True(s.ApplySkillRaise(24u, amount: 10u, xpSpent: 500u)); - - var run = s.GetSkill(24u); - Assert.NotNull(run); - Assert.Equal(22u, run!.Value.Ranks); - Assert.Equal(3956u, run.Value.Xp); - Assert.Equal(132u, run.Value.CurrentLevel); - } - - [Fact] - public void ApplyAttributeRaise_AddsRanksAndSpentXp() - { - var s = new LocalPlayerState(); - s.OnAttributeUpdate(atType: 5u, ranks: 10u, start: 10u, xp: 100u); - - Assert.True(s.ApplyAttributeRaise(atType: 5u, amount: 1u, xpSpent: 25u)); - - var focus = s.GetAttribute(LocalPlayerState.AttributeKind.Focus); - Assert.NotNull(focus); - Assert.Equal(11u, focus!.Value.Ranks); - Assert.Equal(125u, focus.Value.Xp); - Assert.Equal(21u, focus.Value.Current); - } - - [Fact] - public void ApplyVitalRaise_AddsRanksAndSpentXpWithoutChangingCurrent() - { - var s = new LocalPlayerState(); - s.OnVitalUpdate(vitalId: 7u, ranks: 5u, start: 10u, xp: 100u, current: 12u); - - Assert.True(s.ApplyVitalRaise(vitalId: 1u, amount: 1u, xpSpent: 25u)); - - var health = s.Get(LocalPlayerState.VitalKind.Health); - Assert.NotNull(health); - Assert.Equal(6u, health!.Value.Ranks); - Assert.Equal(125u, health.Value.Xp); - Assert.Equal(12u, health.Value.Current); - } - - [Fact] - public void DebitIntProperty_Present_DebitsClampsAndFiresCharacterChanged() - { - var s = new LocalPlayerState(); - var props = new PropertyBundle(); - props.Ints[0x18u] = 3; - s.OnProperties(props); - int changed = 0; - s.CharacterChanged += () => changed++; - - Assert.True(s.DebitIntProperty(0x18u, 2)); - Assert.Equal(1, s.Properties.GetInt(0x18u)); - - Assert.True(s.DebitIntProperty(0x18u, 5)); // over-debit clamps at 0 - Assert.Equal(0, s.Properties.GetInt(0x18u)); - Assert.Equal(2, changed); - } - - [Fact] - public void DebitIntProperty_Absent_FalseAndNoEvent() - { - var s = new LocalPlayerState(); - int changed = 0; - s.CharacterChanged += () => changed++; - - Assert.False(s.DebitIntProperty(0x18u, 1)); - Assert.Equal(0, changed); - } - - [Fact] - public void DebitInt64Property_Present_DebitsAndFiresCharacterChanged() - { - var s = new LocalPlayerState(); - var props = new PropertyBundle(); - props.Int64s[2u] = 500L; - s.OnProperties(props); - int changed = 0; - s.CharacterChanged += () => changed++; - - Assert.True(s.DebitInt64Property(2u, 100L)); - Assert.Equal(400L, s.Properties.GetInt64(2u)); - Assert.Equal(1, changed); - } - - [Fact] - public void DebitInt64Property_ZeroOrAbsent_False() - { - var s = new LocalPlayerState(); - Assert.False(s.DebitInt64Property(2u, 100L)); // absent - - var props = new PropertyBundle(); - props.Int64s[2u] = 0L; - s.OnProperties(props); - Assert.False(s.DebitInt64Property(2u, 100L)); // already 0 — no negative banking - } - [Fact] public void Clear_ReturnsEveryCharacterSnapshotToPreLoginState() { From bd943849b1d1ac2d7fa28af310dcea191172b6c4 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 13:57:51 +0200 Subject: [PATCH 20/89] =?UTF-8?q?docs:=20Campaign=20CA=20CA5=20connected?= =?UTF-8?q?=20gate=20script=20=E2=80=94=20awaiting=20the=20owner=20drive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user-driven verification matrix for the whole advancement chain on a scratch character: live run-speed change under a Quickness raise, the Endurance single-record stamina fan-out, the deliberate raise-10 failure probe that resolves the narrowed AP-73 ghost question, skill raise/train, gem-driven specialize/lower with the confirmation dialog, and a regression sweep. CA1-CA4 are committed and pushed; this script is the campaign's remaining gate. Co-Authored-By: Claude Fable 5 --- ...26-08-24-character-advancement-campaign.md | 2 +- .../2026-08-24-campaign-ca-test-script.md | 142 ++++++++++++++++++ 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 docs/research/2026-08-24-campaign-ca-test-script.md diff --git a/docs/plans/2026-08-24-character-advancement-campaign.md b/docs/plans/2026-08-24-character-advancement-campaign.md index 47c730d0..35eacb65 100644 --- a/docs/plans/2026-08-24-character-advancement-campaign.md +++ b/docs/plans/2026-08-24-character-advancement-campaign.md @@ -123,4 +123,4 @@ neighborhood). | CA2 | COMPLETE 2026-08-24 (`65430d4c`) | 0x02E3/0x02DD parsers + WorldSession events + router routing into LocalPlayerState; conformance tests incl. holtburger golden fixture; 0x02DF deliberately unparsed (no ACE producer) | | CA3 | COMPLETE 2026-08-24 | Live formula recompute (SkillFormulaBonusResolver over RetailSkillFormula) on attribute writes + fresh-train derivation; movement re-applied down the PD seam (PushMovementSkillTotals — Quickness raise → run speed, no relog); vitals bar pull-model verified; router behavior + fresh-train tests | | CA4 | COMPLETE 2026-08-24 | Optimistic ApplyLocalRaise layer DELETED; retail one-in-flight + ghost + server-authoritative flow ported per the pinned §5 pseudocode (AP-73 NARROWED — rejection-release semantics owed to CA5 live); train cost verified DAT-exact; specialize correctly has no panel send (gem + confirmation route, seams already present); provider contract tests rewritten | -| CA5 | — | | +| CA5 | SCRIPT WRITTEN 2026-08-24 — awaiting the owner drive | docs/research/2026-08-24-campaign-ca-test-script.md (scratch character; §3.2 resolves the narrowed AP-73) | diff --git a/docs/research/2026-08-24-campaign-ca-test-script.md b/docs/research/2026-08-24-campaign-ca-test-script.md new file mode 100644 index 00000000..cae336db --- /dev/null +++ b/docs/research/2026-08-24-campaign-ca-test-script.md @@ -0,0 +1,142 @@ +# Campaign CA — connected gate script (CA5, user-driven) + +**Purpose:** live verification of the whole advancement chain against ACE +after CA1–CA4 (`1fc64984`, `65430d4c`, `57818959`, `08b77e20`). Everything +below should be visible **without a relog** — that is the entire point of +the campaign. + +**Character:** use a SCRATCH character (owner decision 2026-08-24 — XP and +skill credits will be spent, and the respec checks are destructive). +A fresh character is ideal: low attribute costs mean many cheap raises. + +**Launch:** the normal connected launch (`ACDREAM_RETAIL_UI=1`, live ACE at +`127.0.0.1:9000`). No diagnostic env vars needed. Useful ACE console +helpers: `@ci ` to spawn gems, `@grantxp`, `@grantskillcredits` +(see `claude-memory/../memory/reference_ace_commands.md`). + +Open the Character panel (F9 / toolbar) before starting; keep the vitals +bar visible throughout. + +--- + +## 1. Attribute raise → derived skills + run speed (the original #431) + +Prep: bank some XP (`@grantxp` if the scratch character is too poor). +Note current run speed by running a straight line; note the Run skill's +displayed value and one other Quickness-fed skill (e.g. Melee Defense). + +1. Raise **Quickness** by 1. + - PASS: the attribute value updates when the server record lands (a + round trip, not a relog); **Run and every Quickness-fed skill row + update in the same moment**; XP remaining drops by the server's + accounting. +2. Raise Quickness repeatedly until the formula contribution crosses a + point (attribute current +2 → skill formula +1 for /2-divisor skills). + - PASS: skill rows tick up as the attribute crosses each threshold. +3. **Run before and after.** With `runrate_add_hooks` active on ACE the + server also re-broadcasts your movement speed mid-run. + - PASS: run speed visibly increases after the raise — at latest on the + next movement start. FAIL if speed only changes after relog. +4. While the raise is in flight (click and watch closely): the raise + buttons ghost momentarily and un-ghost when the record lands. + - PASS: brief ghost; no double-send on rapid double-click (the second + click does nothing). + +## 2. Endurance/Self raises → vitals maxima (the single-record quirk) + +Note max health / max stamina / max mana from the vitals bar. + +1. Raise **Endurance** by 1–2 points. + - PASS: **max health AND max stamina both move** on the bar and the + panel (ACE only pushes a Health record; the client-side fan-out must + cover Stamina — research doc §4.1). +2. Raise **Self**. + - PASS: max mana moves. +3. Sanity: current values don't jump wrongly (regen keeps ticking + normally; watch ~10 s). + +## 3. Direct vital raise + the retail raise-10 client bug (AP-73 CHECK) + +1. Raise **Max Health** directly by 1. + - PASS: bar max + panel update on the record; XP debits. +2. **The deliberate failure case** — this resolves the narrowed AP-73 row. + Arrange XP so you can afford exactly ONE vital raise but not ten + (spend down; the cost curve is steep so this is easy). The retail + client bug ACE documents: the raise-10 button is enabled anyway. + Click **raise ×10**. + - EXPECTED from ACE: chat line "Your attempt to raise ... has failed." + and NO stat change. + - **RECORD: do the raise buttons stay ghosted afterward?** + - If they un-ghost on their own → note what un-ghosted them (a regen + tick counts as a quality change — that is retail-plausible and + AP-73 can then RETIRE with that mechanism recorded). + - If they stay ghosted until you close/reopen the panel → AP-73's + symptom confirmed; report it and we decide the fix against the + retail oracle (cdb on the live retail client if needed). + +## 4. Skill raise + +1. Select a TRAINED skill, raise ×1 and ×10. + - PASS: ranks/value update on the record; XP debits; the + "Your base skill is now N!" advancement line appears in the + SpewBox with the advancement color. +2. Confirm an attribute-less skill (e.g. **Salvaging**, if trained) shows + value = ranks-only progression and raises normally. + +## 5. Train a new skill + +Prep: ensure ≥ the DAT cost in skill credits (`@grantskillcredits`). + +1. Select an UNTRAINED skill, click Train. + - PASS: the skill flips to Trained on the record; credits drop by the + DAT cost; chat: " trained. You now have N credits available." +2. NEGATIVE (silent-failure probe): nothing client-side should allow a + wrong cost, so simply confirm no double-send on rapid clicks and that + the button ghosts while awaiting. + +## 6. Specialize / lower (SkillAlterationDevice) + +Prep: `@ci` a **Gem of Enlightenment** (specialize) and a +**Gem of Forgetfulness** (lower) — wcids per ACE's db (ask the console +with `@acecommands` if unsure). + +1. Use the Enlightenment gem on a TRAINED skill. + - PASS: retail confirmation dialog appears; on accept, the skill flips + to Specialized, credits drop, and the "You have succeeded + specializing..." notice lands in chat (the 0x028B + WeenieErrorWithString route). + - Also confirm DECLINING the dialog changes nothing. +2. Use the Forgetfulness gem: Specialized → Trained, then Trained → + Untrained. + - PASS: each step updates the panel on the record and refunds credits + per ACE's accounting; an untrained skill row returns to the + untrained section with formula-only value. + +## 7. Respec-adjacent (as far as ACE supports) + +If ACE's Enlightenment/attribute-reset paths are reachable on this server +(level requirements may block a scratch character — skip if so), exercise +one and confirm the client tracks every pushed record without a relog. +Otherwise mark N/A — the message shapes are identical to §6's, so §6 +passing covers the client-side mechanism. + +## 8. Regression sweep (5 minutes) + +- Vitae/buff display still correct after raises (buff an attribute; panel + shows effective + base pair; skill values include the buff through the + formula). +- Logout/login: everything you raised persists and PlayerDescription + agrees with what the live records showed (any mismatch = a parser bug — + report exact numbers). +- Ordinary play smoke: run, jump, cast, fight one mob — nothing about + movement feel changed outside the raises. + +--- + +## Report back + +Per section: PASS/FAIL plus anything odd. The three answers that matter +most: +1. §1.3 — did run speed change live? +2. §2.1 — did max STAMINA move on an Endurance raise? +3. §3.2 — the AP-73 ghost question (un-ghosted by what / stayed stuck?). From bce17b3cfbd7c9684b2d639f0fb27baf3f2af705 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 16:51:58 +0200 Subject: [PATCH 21/89] =?UTF-8?q?fix=20#430=20#440:=20character=20panel=20?= =?UTF-8?q?=E2=80=94=20tooltips=20can=20mount,=20and=20rows=20refresh=20on?= =?UTF-8?q?=20the=20authoritative=20record?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects the owner found at the CA5 drive, one shared theme: the data was right and the presentation seam was dead. #430 (tooltips): the TS-85 Batch-B port set runtime TooltipText on the runtime-built attribute/vital/skill rows but never gave them a popup locator, and RetailTooltipPresenter.OnTooltipShow refuses any widget with AuthoredTooltipRootElementId == 0 — the tooltip could never mount, on any row, ever. (The register's 'live-verified on the Character tab' was the OPTIONS panel's Character tab — authored elements with authored locators; a different surface.) Rows now carry the shared popup skin 0x10000395/0x21000041 — live-DAT probed as the ONLY locator pair the character layout references, and the same inference UiItemSlot already ships for runtime-built widgets. TS-85's row carries the dated correction. #440 (train row stuck): training a skill debited credits on screen but left the row in the untrained section until the NEXT click — because the sheet-changed subscription only refreshed the captured sheet, and row STRUCTURE rebuilt exclusively in click handlers (the raise 'completed' callback runs after SEND, before the server answers; the owner's second click was simply the first rebuild after the record landed, and ACE's rejection of that second train — 'Failed to train', no credit change — matches the owner's report exactly). The same gap kept CA4's awaiting-ghost from visually releasing. CharacterStatController.Bind now returns the data-changed refresh and MountCharacter invokes it on every authoritative sheet change, mirroring retail's quality-change broadcast (InfoRegion::OnQualityChanged @ 0x004F0EB0). Pinned by DataChangedRefresh_MovesATrainedSkillToItsSection_WithoutAClick and Rows_CarryTheSharedTooltipPopupLocatorAndDescriptionText. Owner visual re-check owed next session (hover-dwell a row; train a skill and watch it move immediately). Full hermetic suite 15,329 passed / 0 failed. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 44 +++++++- .../retail-divergence-register.md | 2 +- .../UI/Layout/CharacterStatController.cs | 24 ++++- src/AcDream.App/UI/RetailUiRuntime.cs | 14 ++- .../UI/Layout/CharacterStatControllerTests.cs | 100 ++++++++++++++++++ 5 files changed, 177 insertions(+), 7 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 131df0a0..6b9fd3fa 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,34 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #440 — CLOSED: Trained skill did not move to its section (and raise buttons never visually un-ghosted) until the next click + +**Status:** CLOSED 2026-08-24, found by the owner during the CA5 drive. +**Component:** character panel / row refresh + +**Symptom (owner):** training an untrained skill consumed credits (visible +immediately) and the confirmation text appeared, but the skill stayed in +the untrained section; clicking Train AGAIN (which the server rejects — +"Failed to train", no credit change) made it move. + +**Root cause:** the panel's sheet-changed subscription only refreshed the +captured `currentSheet` — per-frame text pulls (credits, values) updated, +but the ROW STRUCTURE (section buckets, selection, raise-button states) +rebuilt only on clicks (`RefreshAfterRaise` ran as the raise's `completed` +callback — synchronously after SEND, before the server's answer). The +second click was simply the first row rebuild after the record landed. +The same gap kept CA4's awaiting-ghost from visually releasing. + +**Fix:** `CharacterStatController.Bind` now returns the data-changed +refresh (`RefreshAfterRaise(null)` — rebuild + reselect + re-evaluate +buttons), and `RetailUiRuntime.MountCharacter`'s subscription invokes it on +every authoritative sheet change — mirroring retail's quality-change +broadcast (`InfoRegion::OnQualityChanged @ 0x004F0EB0` → +`ListenToElementMessage @ 0x004EFBE0`). Pinned by +`DataChangedRefresh_MovesATrainedSkillToItsSection_WithoutAClick`. + +--- + ## #439 — Flake candidate: LossySession_FivePercentSeeded_ZeroMessageLoss_Headroom256 fails under full parallel suite load **Status:** OPEN (observation filed; do NOT chase individually per docs/release-gate.md) @@ -419,9 +447,21 @@ immediately (F2 overlay shows guids). --- -## #430 — No tooltips on skills and attributes in the character panel +## #430 — CLOSED: No tooltips on skills and attributes in the character panel -**Status:** OPEN +**Status:** CLOSED 2026-08-24 (found during the CA5 gate follow-up). +Root cause: the TS-85 Batch-B port set runtime `TooltipText` on the +runtime-built rows but never gave them a popup locator, and +`RetailTooltipPresenter.OnTooltipShow` refuses any widget with +`AuthoredTooltipRootElementId == 0` — the tooltip could NEVER mount. +(TS-85's "live-verified on the Character tab" was the OPTIONS panel's +Character tab — authored elements with authored locators.) Fix: rows now +carry the shared popup skin `0x10000395`/`0x21000041` — live-DAT probed as +the ONLY locator pair layout `0x2100002E` references, and the same +inference `UiItemSlot` already ships for runtime-built widgets. Pinned by +`Rows_CarryTheSharedTooltipPopupLocatorAndDescriptionText`. Owner visual +re-check owed at the next session (hover a row, hold the mouse still for +the 0.25 s dwell). **Severity:** LOW (information affordance missing) **Filed:** 2026-08-23 (owner report) **Component:** retail UI / character panel diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 9738eb3c..e0942e31 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -423,7 +423,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| -| TS-85 | **Filed 2026-08-16 at #409 (client-wide retail tooltip system); REWRITTEN at the same-day F3 review round; NARROWED again at the same-day live-failure round.** LIVE-FAILURE-ROUND NARROWING: the `m_TTText` READ side is now ported — `RetailTooltipPresenter.ResolveTooltipText` consults `UiElement.GetTooltipText()` (this port's `m_TTText`) BEFORE the authored `P0x49`, exactly as `StartTooltipAtMouse @0x00460DA3`/`@0x00460DDF` orders them, and the `P0x48`-absent fallback to the element's own layout (`@0x00460E7E`) is ported through `UiElement.SourceLayoutDid`. That lit up every acdream surface whose controller ALREADY writes runtime tooltip text (the four Options tabs, Configure Keyboard, the social pages) — live-verified 2026-08-16 on the Character tab. What remains deferred is the WRITE side at the retail `SetTooltip` call sites acdream has no analog for yet, enumerated below. Two sub-mechanisms of retail's tooltip system are unported. **(1) The `m_TTText`/`SetTooltip` runtime-text family (headed by the `P0xD0` truncated-text auto-tooltip):** the ORIGINAL filing argued this port's gap was "retail's dynamic `InqProperty(0x49)` override" — that framing is false. `UIElement::InqProperty @0x004638D0`, the BASE implementation every element uses unless its own class overrides the virtual, reads exactly the same authored property bags (`m_instanceProperties`, `m_curStateDesc`, `m_desc`) this port's `ElementReader` already walks generically — so an element with no literal `P0x49` gets NOTHING from retail's own default `InqProperty` either. The REAL second text source is the element's cached `m_TTText` field, set ONLY by the explicit, non-dat `UIElement::SetTooltip` call (`UIElement::StartTooltipAtMouse @0x00460D70` prefers `m_TTText` over the `InqProperty` fallback whenever it is non-empty). `SetTooltip` has 15+ known game-code call sites (Options rows `@0x00485E65`, chargen `@0x00481981`, the paperdoll endowment icon `@0x004C63A1`, the spellcast button `@0x004C6FE8`/`@0x004C6AAE`, and more), headed by the highest-volume one: `UIElement_Text::RecalculateTruncation @0x00466F80`, gated on authored `P0xD0` — an overflowing single/wrapped line calls `SetTooltip(this, ownText) @0x00467064` + sets enable bit 5 `@0x00467076`; a line that now fits calls `ClearTooltip @0x00467064`/clears the bit `@0x00466ff9`. `RecalculateTruncation`'s own truncation-POSITION computation (the rest of the function, `@0x004670a1` onward) walks a `GlyphList` per-line-position model (`FindCompleteLineFromY`/`FindPosFromLineAndPixels`/`FindPixelsFromPos`) this port's `UiText` has no equivalent of — `UiText` clips visually via a scissor rect (`DrawClippedText`'s `PushClip`) with no tracked "does this line overflow" state at all, so porting the auto-tooltip trigger requires building that state first. Sized as genuinely disproportionate for a single fix-round commit alongside F1-F2/F4-F11 and deferred here rather than shipped as a partial/unverified stub. A live-DAT sweep found 187 of the 430 elements authoring at least one tooltip-trigger property have NO literal `P0x49` `StringInfo` text; the live-failure round re-measured that set and found every one of the 187 authors BOTH popup-locator ids (`P0x47`+`P0x48`) — i.e. they are runtime-`SetTooltip` targets by construction, waiting only for text. **F12 correction (night-round review, 2026-08-17): this is 17 sites, not 15** — the original tally dropped `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` (mentioned two sentences below as its own closed row) and undercounted by one more besides. The 17 `SetTooltip` call sites, enumerated from the decomp at the live-failure round, split into: PORTED (an acdream controller already writes the text, and the presenter now reads it) — the Options rows `@0x00485E65`/`@0x00484803`/`@0x00487053`, chargen skills `@0x00481981`, the radar `@0x004D9605`; PORTED 2026-08-16 (hover-feedback completion round, docs/ISSUES.md #409/#411): inventory/shortcut item hover `UIElement_UIItem::UpdateTooltip @0x004E1CB0` — `UiItemSlot` now hardcodes the catalog's uniform popup locator (`P0x47=0x10000395`/`P0x48=0x21000041`, live-DAT-confirmed uniform across all 47 UIItem-type catalog prototypes, `TooltipLiveDatTests.UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator`) and a `TooltipTextResolve` delegate wired at every physical-item construction site (inventory, external container, paperdoll — closing the separate `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` row below too, vendor, secure trade, toolbar), backed by the new `ClientObject.GetTooltipDisplayName()` (NAME_APPROPRIATE + the `"%d %s"` stack-count prefix, matching the decomp exactly); and the SmartBox found-object world-hover tooltip `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004E5AD0` (`@0x004E5D74`/`@0x004E5DFB`) — `RetailTooltipPresenter.UpdateWorldHoverTooltip` ports its `PlayerModule::ShowTooltips`-gated, `GetAppropriateName`-only (no stack prefix — a real, decomp-confirmed asymmetry vs. the item-cell case) trigger — **TIMING CORRECTED at the 2026-08-17 morning gate round (user finding: retail world tooltips "lag"; ours popped instantly): the original "edge-fired (no dwell)" reading was a misread — the notice's immediate `StartTooltipAtMouse @0x004E5DFB` sits inside `if (s_pInstance->m_dragElement != 0)` (`@0x004E5D8E`; `m_dragElement` is a real, distinct PDB field in `acclient.h`'s `UIElementManager`, separate from the `m_pTooltipElement` family), so the immediate mount is DRAG-ONLY; the ordinary hover path merely STAGES the name (`SetTooltip @0x004E5D74` + `|= 0x20`) and the display rides `CheckTooltip @0x0045B6E0`'s mouse-idle dwell (`m_lastMouseMoveTime` stamped on EVERY move `@0x0045e736` + `m_tooltipDelay` 0.25 s `@0x0045f75d`), with found-object changes under an idle mouse swapping the popup same-frame via `SetTooltip`'s own text-change teardown (`@0x004617FF` → `ResetTooltip @0x0045C360` tail-calling `CheckTooltip`) and the 10 s `m_tooltipDuration` expiry requiring a fresh mouse move before re-arming (`SwitchMouseOver(null) @0x0045b7b2`) — all now ported, including the drag-immediate branch**, reusing the SAME popup locator since an exhaustive DAT sweep found `UIElement_SmartBoxWrapper` (class `0x10000030`) has no authored `ElementDesc` anywhere installed (`TooltipLiveDatTests.SmartBoxWrapper_HasNoAuthoredElementDesc_AnywhereInstalled`) — the popup-skin choice is therefore the best-evidenced inference, not a measured value, and is called out here as such. **BATCH B (2026-08-17) CLOSED the spellcasting and character-panel rows of this list. BATCH C (2026-08-17, Map/House toolbar panel) CLOSES THE LAST REMAINING ITEM: `gmMapUI::AddMapNote @0x004A1C51`'s 53 town-hotspot tooltips are now ported via `MapPageController.BuildTownMarkers` (`src/AcDream.App/UI/Layout/MapPageController.cs`), setting `UiButton.TooltipText` (retail's RUNTIME `m_TTText`/`SetTooltip` mechanism, not the DAT-authored `P0x49` path an earlier same-day cut mistakenly used and which never rendered live during verification); literal town names from `MapLocations.cs` (a verbatim port of `s_rgLocations`), not a DAT string-table lookup, matching `AddMapNote`'s own `StringInfo::SetLiteralValue` call. CORRECTED at the same-day morning gate round (user finding 3 — the retail screenshot's green hover highlight + special-font parchment tooltip): Batch C's "the town-marker template authors no locator of its own" claim was WRONG — the template (`0x100001F0` in `0x21000026`, `MapNoteLiveDatTests`) authors its OWN `P0x47=0x10000398`/`P0x48=0x21000041` (the fourth popup skin, whose incorporated text child `0x10000396` fonts `0x40000015` where the other three skins font `0x40000002`), a zero per-element delay `P0x50=0.0`, `P0x4B` TooltipOn, and `P0x13` RolloverEnabled with PassToChildren `Normal`/`Normal_rollover` states flipping the highlight child `0x100001F1`'s per-state `P0x3B` (the green `0x06004CC9` frame, byte-decoded A=FF R=00 G=FF B=00); the hardcoded shared-skin override was removed (the built marker's authored locator wins) and the rollover highlight + per-state-`P0x3B` + button PassToChildren cascade are now ported (`UiButton.CascadeStateToChildren`, `UiDatElement.TrySetRetailState`'s 0x3B honor). Sub-mechanism (1)'s `SetTooltip`-call-site enumeration is 16 of 17 known sites PORTED — `UIElement_Text::RecalculateTruncation @0x00466F80` (the headline, highest-volume site named at the top of sub-mechanism (1)) remains the ONE open item, exactly as this row's own sub-mechanism (1) text above already scoped it out (its own "Sized as genuinely disproportionate... deferred here" note). The prior "all 15 known sites accounted for" close (F12 correction, night-round review) was wrong twice over: the count is 17, not 15, and RecalculateTruncation was never actually ported — it was always the one deliberately-deferred item, not a closed one.** Batch B audit findings: the endowment icon `@0x004C63A1`, favorite `@0x004C7206`, and submenu `@0x004C67D8` sites turned out to be ALREADY CORRECT — all three are `UiCatalogSlot`-based and the pre-existing `Label`-driven `GetTooltipText` already carried retail's exact text (`SpellCastSubMenu::AddFavorite @0x004C7060`/`UpdateFromPlayerModule @0x004C6570` both build a single-arg `Formatted` PStringBase — plain spell name, no wrapper — for the favorite-bar/submenu case; `gmSpellcastingUI::UpdateEndowmentIcon @0x004C6120` mirrors the cast button's confirmed `"%s (%hs)"` literal at `data_7b64d8` — item name, then spell name in parens — via the identical two-value narrow/wide prep sequence, one address away in the same function family). The cast button `@0x004C6FE8`/`ClearTooltip @0x004C6AAE` was the one real gap (`UiButton` had no tooltip wiring at all): now ported via `SpellcastingUiController.UpdateCastAvailability`/`ComputeEndowmentCastState`, sourced from `gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30`. Fully verified literal text: the no-selection states (`"Select a spell to cast"` @ `data_7b64ec`, `"You have no spells ready to cast"` @ `data_7b6520`) and the complete endowment-item branch (`"USE the %s"` @ `data_7b64c0`, `" on %s"` @ `data_7b6464`, `"You must select a target for the %s"` @ `data_7b6478`; `ItemUses::IsUseable_SelfTarget @0x004fcd30` is exactly `ItemUseability.AllowsSelfTarget`). NOT ported: the endowment branch's incompatible-target sub-state (`"You must select an appropriate\ntarget for the %s"` @ `data_7b6400`, gated by `ItemHolder::TargetCompatibleWithObject @0x00587520` — a ~400-line function with its own chat-message side effects, out of scope for a tooltip batch; a present target is optimistically treated as compatible, same text as the confirmed-compatible case). **CORRECTED at the night-round review (F3/F4, 2026-08-17): the plain-spell branch's wording is NOT unrecoverable — the "genuine `gmNoticeHandler` vtable SLOTS" claim above was itself the artifact. `PStringBase::sprintf`'s second argument at those three call sites is a raw pushed literal (a plain `push 0x7bXXXX; call sprintf`); Binary Ninja's pseudo-C rendering of that operand as `&gmSpellcastingUI::\`vftable'.RecvNotice_XXX` was a spurious symbol match, not the true operand — a direct capstone disassembly of the raw bytes at `0x4c6e48`/`0x4c6ea4`/`0x4c6f18`/`0x4c6f5d` resolves the actual constants: `"CAST %hs"` @0x7b63a4 (untargeted/self-cast at `0x4c6f35`, and targeted+compatible at `0x4c6e57` — both enabled, the latter appending `" on %s"` @0x7b6464 with the target's name), `"You must select an appropriate target for %hs"` @0x7b6348 (targeted+incompatible, disabled), `"You must select a target for %hs"` @0x7b63b8 (no target, disabled); `%hs` is the spell's own name in all four call sites (`CSpellBase::InqName`, the same call `0x5bbee0` throughout). Now ported: `RuntimeSpellCastState.EvaluateCastGate` (the four-state gate) + `SpellcastingUiController.ComputeSpellCastState`. Also corrected the endowment branch's "USE the %s" operand: it was NOT the bare item name (F4) — the vararg to `"USE the %s"`/`"You must select a target for the %s"`/the still-unported incompatible-target string is the SAME composed `"%s (%hs)"` string (item name, spell name) built once at `@0x004c6bb6-ef` from format literal `data_7b64d8`, byte-confirmed by all three sprintf call sites (`0x4c6c7f`/`0x4c6ca4`/`0x4c6d46`) reading the identical `[esp+0x18]` slot — now ported via `SpellcastingUiController.ComposeEndowmentName`.** The character-panel `AttributeInfoRegion @0x004F1617` / `Attribute2ndInfoRegion @0x004F1777` / `SkillInfoRegion @0x004F222F` constructors are now fully ported through a new `UiClickablePanel.TooltipText` (the same settable-string seam as `UiButton.TooltipText`): the six hardcoded attribute descriptions (`SkillSystem::InqAttributeDescription @0x005c8e30`) and three hardcoded, pair-shared vitals descriptions (`SkillSystem::InqAttribute2ndDescription @0x005c8f70`) were byte-decoded from the retail string pool (the pseudo-C dump truncates them with "…"); skill tooltips compose `SkillInfoRegion::GetTooltip @0x004f1fe0`'s exact `"\n" + formula + description` (ported verbatim, including the confirmed lack of any separator between the formula and description text) from the ALREADY-DAT-parsed `DatReaderWriter.Types.SkillBase.Description`/`.Formula` fields (portal `0x0E000004`, the same resource `CharacterSheetProvider.SkillTable` already reads for skill names/costs) rather than hand-transcribed literals — no guessing was needed for the ~30+ skill description strings. The formula-to-text algorithm itself (`SkillSystem::InqSkillFormula @0x005c89b0`, e.g. producing `"( (Strength + Coordination) / 2 )"`) was fully recovered by byte-decoding six short literal fragments (`data_7e7930`/`7e7934`/`7e7940`/`7e7950`/`7e7954`/`797584`) the pseudo-C dump left completely unlabeled — they sit between two `gmSpellcastingUI` vtable declarations and Binary Ninja's type inference never recognized them as strings, so the raw hex had to be read directly as narrow ASCII (confirmed against the function's own directly-visible `" / %u"` and `"(%u x %s)"` literals, which needed no such recovery). Retail's runtime sites also SET the `P0x4B` on-bit themselves (`__bitfield164 |= 0x20`, eight sites) — the port models that as "runtime text present implies tooltip-on", so only the authored-text path consults the authored bit. **(2) The per-element wrap-width override:** `UIElement_Text::InqSizewMargins @0x00469660`'s `UITS_MAX_WIDTH` branch checks `GetAttribute_Int(this, 0x3D, ...)` before falling back to `RenderDevice::GetDisplayWidth()`; `RetailTooltipPresenter.ApplyTooltipText` always wraps at `UiRoot.EffectiveCanvasSize.X` (the confirmed fallback) and never checks for a `P0x3D` override — the live-DAT sweep found zero tooltip-bearing elements author one. | `src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs` (`ResolveTooltipText`'s runtime-then-authored order; `ApplyTooltipText`'s wrap-width literal; `UpdateWorldHoverTooltip`/`TryBuildAndMountPopup` — the world-hover half added 2026-08-16); `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo.TooltipText`'s own doc comment carries the same F3 correction); `src/AcDream.App/UI/UiItemSlot.cs` (`TooltipTextResolve`, `GetTooltipText`, the hardcoded popup-locator constants); `src/AcDream.Core/Items/ClientObject.cs` (`GetTooltipDisplayName`); `src/AcDream.App/UI/CursorFeedbackController.cs` (the related #411 found-cursor fix, same round — see that row); Batch B (2026-08-17) additions: `src/AcDream.App/UI/UiPanel.cs` (`UiClickablePanel.TooltipText`/`GetTooltipText`); `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`UpdateCastAvailability`, `ComputeEndowmentCastState`); night-round review (F3/F4, 2026-08-17) additions: `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`ComputeSpellCastState`, `ComposeEndowmentName`); `src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs` (`EvaluateCastGate`, `SpellCastGate`); `src/AcDream.App/Net/RetailSkillFormula.cs` (`AttributeName`, `FormatFormula`, `BuildTooltip`); `src/AcDream.App/UI/Layout/CharacterSheet.cs` (`CharacterSkill.TooltipText`); `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`BuildLiveCharacterSkills`'s tooltip compose); `src/AcDream.App/UI/Layout/CharacterStatController.cs` (`AttributeDescriptions`, `Attribute2ndDescriptions`, `BuildAttributeRows`/`BuildSkillRows` row wiring) | The 243 elements WITH literal text — the "core" case #409 ships — cover every hover-text scenario the investigation's own landmark checks exercised (main-game-UI Appearance-page rotate/color/spin hints, etc.). F9 correction: "243 with literal text" is not automatically "243 showable" — `RetailTooltipPresenter.OnTooltipShow`'s real gate is the FULL conjunction of `TooltipEnabled` (P0x4B) AND non-null text (P0x49) AND both popup-locator ids (P0x47/P0x48) — so this was measured, not assumed: `TooltipLiveDatTests.ClientWideSweep_FindsKnownLandmarksAndAFloorCount`'s `Showable` column finds the intersection is exactly 243, i.e. every element authoring literal text also authors the other three properties together (they are evidently authored as one group in practice). The P0x3D sweep found zero authoring elements, so the unconditional display-width fallback is not an approximation for any element that exists today. | If a future DAT revision adds a `P0xD0`-truncated text element, a game-code `SetTooltip` caller, or authors a `P0x3D` override, it silently shows no tooltip / wraps at the wrong width instead of erroring — indistinguishable from "the element authors no tooltip at all" without re-running the sweep AND separately auditing which of the 187 no-literal-text elements would actually truncate at their authored width. | `UIElement::InqProperty @0x004638D0` (base authored-bag read, NOT a dynamic override); `UIElement::StartTooltipAtMouse @0x00460D70` (`m_TTText`-vs-`InqProperty` preference order); `UIElement_Text::RecalculateTruncation @0x00466F80` (`P0xD0` gate, `SetTooltip`/`ClearTooltip` sites); `UIElement_Text::InqSizewMargins @0x00469660` (`UITS_MAX_WIDTH` branch, `GetAttribute_Int(this, 0x3D, ...)`); Batch B (2026-08-17): `gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30` (cast-button state machine); `gmSpellcastingUI::UpdateEndowmentIcon @0x004C6120` (endowment-icon `"%s (%hs)"` confirmation); `SpellCastSubMenu::AddFavorite @0x004C7060` / `UpdateFromPlayerModule @0x004C6570` (favorite/submenu plain-name confirmation); `ItemUses::IsUseable_SelfTarget @0x004fcd30`; `AttributeInfoRegion::AttributeInfoRegion @0x004f1530` / `Attribute2ndInfoRegion::Attribute2ndInfoRegion @0x004f1680` / `SkillInfoRegion::SkillInfoRegion @0x004f2140` / `SkillInfoRegion::GetTooltip @0x004f1fe0`; `SkillSystem::InqAttributeName @0x005c8d90` / `InqAttributeDescription @0x005c8e30` / `InqAttribute2ndName @0x005c8ed0` / `InqAttribute2ndDescription @0x005c8f70` / `InqSkillFormula @0x005c89b0` | +| TS-85 | **Filed 2026-08-16 at #409 (client-wide retail tooltip system); REWRITTEN at the same-day F3 review round; NARROWED again at the same-day live-failure round.** LIVE-FAILURE-ROUND NARROWING: the `m_TTText` READ side is now ported — `RetailTooltipPresenter.ResolveTooltipText` consults `UiElement.GetTooltipText()` (this port's `m_TTText`) BEFORE the authored `P0x49`, exactly as `StartTooltipAtMouse @0x00460DA3`/`@0x00460DDF` orders them, and the `P0x48`-absent fallback to the element's own layout (`@0x00460E7E`) is ported through `UiElement.SourceLayoutDid`. That lit up every acdream surface whose controller ALREADY writes runtime tooltip text (the four Options tabs, Configure Keyboard, the social pages) — live-verified 2026-08-16 on the Character tab. What remains deferred is the WRITE side at the retail `SetTooltip` call sites acdream has no analog for yet, enumerated below. Two sub-mechanisms of retail's tooltip system are unported. **(1) The `m_TTText`/`SetTooltip` runtime-text family (headed by the `P0xD0` truncated-text auto-tooltip):** the ORIGINAL filing argued this port's gap was "retail's dynamic `InqProperty(0x49)` override" — that framing is false. `UIElement::InqProperty @0x004638D0`, the BASE implementation every element uses unless its own class overrides the virtual, reads exactly the same authored property bags (`m_instanceProperties`, `m_curStateDesc`, `m_desc`) this port's `ElementReader` already walks generically — so an element with no literal `P0x49` gets NOTHING from retail's own default `InqProperty` either. The REAL second text source is the element's cached `m_TTText` field, set ONLY by the explicit, non-dat `UIElement::SetTooltip` call (`UIElement::StartTooltipAtMouse @0x00460D70` prefers `m_TTText` over the `InqProperty` fallback whenever it is non-empty). `SetTooltip` has 15+ known game-code call sites (Options rows `@0x00485E65`, chargen `@0x00481981`, the paperdoll endowment icon `@0x004C63A1`, the spellcast button `@0x004C6FE8`/`@0x004C6AAE`, and more), headed by the highest-volume one: `UIElement_Text::RecalculateTruncation @0x00466F80`, gated on authored `P0xD0` — an overflowing single/wrapped line calls `SetTooltip(this, ownText) @0x00467064` + sets enable bit 5 `@0x00467076`; a line that now fits calls `ClearTooltip @0x00467064`/clears the bit `@0x00466ff9`. `RecalculateTruncation`'s own truncation-POSITION computation (the rest of the function, `@0x004670a1` onward) walks a `GlyphList` per-line-position model (`FindCompleteLineFromY`/`FindPosFromLineAndPixels`/`FindPixelsFromPos`) this port's `UiText` has no equivalent of — `UiText` clips visually via a scissor rect (`DrawClippedText`'s `PushClip`) with no tracked "does this line overflow" state at all, so porting the auto-tooltip trigger requires building that state first. Sized as genuinely disproportionate for a single fix-round commit alongside F1-F2/F4-F11 and deferred here rather than shipped as a partial/unverified stub. A live-DAT sweep found 187 of the 430 elements authoring at least one tooltip-trigger property have NO literal `P0x49` `StringInfo` text; the live-failure round re-measured that set and found every one of the 187 authors BOTH popup-locator ids (`P0x47`+`P0x48`) — i.e. they are runtime-`SetTooltip` targets by construction, waiting only for text. **F12 correction (night-round review, 2026-08-17): this is 17 sites, not 15** — the original tally dropped `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` (mentioned two sentences below as its own closed row) and undercounted by one more besides. The 17 `SetTooltip` call sites, enumerated from the decomp at the live-failure round, split into: PORTED (an acdream controller already writes the text, and the presenter now reads it) — the Options rows `@0x00485E65`/`@0x00484803`/`@0x00487053`, chargen skills `@0x00481981`, the radar `@0x004D9605`; PORTED 2026-08-16 (hover-feedback completion round, docs/ISSUES.md #409/#411): inventory/shortcut item hover `UIElement_UIItem::UpdateTooltip @0x004E1CB0` — `UiItemSlot` now hardcodes the catalog's uniform popup locator (`P0x47=0x10000395`/`P0x48=0x21000041`, live-DAT-confirmed uniform across all 47 UIItem-type catalog prototypes, `TooltipLiveDatTests.UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator`) and a `TooltipTextResolve` delegate wired at every physical-item construction site (inventory, external container, paperdoll — closing the separate `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` row below too, vendor, secure trade, toolbar), backed by the new `ClientObject.GetTooltipDisplayName()` (NAME_APPROPRIATE + the `"%d %s"` stack-count prefix, matching the decomp exactly); and the SmartBox found-object world-hover tooltip `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004E5AD0` (`@0x004E5D74`/`@0x004E5DFB`) — `RetailTooltipPresenter.UpdateWorldHoverTooltip` ports its `PlayerModule::ShowTooltips`-gated, `GetAppropriateName`-only (no stack prefix — a real, decomp-confirmed asymmetry vs. the item-cell case) trigger — **TIMING CORRECTED at the 2026-08-17 morning gate round (user finding: retail world tooltips "lag"; ours popped instantly): the original "edge-fired (no dwell)" reading was a misread — the notice's immediate `StartTooltipAtMouse @0x004E5DFB` sits inside `if (s_pInstance->m_dragElement != 0)` (`@0x004E5D8E`; `m_dragElement` is a real, distinct PDB field in `acclient.h`'s `UIElementManager`, separate from the `m_pTooltipElement` family), so the immediate mount is DRAG-ONLY; the ordinary hover path merely STAGES the name (`SetTooltip @0x004E5D74` + `|= 0x20`) and the display rides `CheckTooltip @0x0045B6E0`'s mouse-idle dwell (`m_lastMouseMoveTime` stamped on EVERY move `@0x0045e736` + `m_tooltipDelay` 0.25 s `@0x0045f75d`), with found-object changes under an idle mouse swapping the popup same-frame via `SetTooltip`'s own text-change teardown (`@0x004617FF` → `ResetTooltip @0x0045C360` tail-calling `CheckTooltip`) and the 10 s `m_tooltipDuration` expiry requiring a fresh mouse move before re-arming (`SwitchMouseOver(null) @0x0045b7b2`) — all now ported, including the drag-immediate branch**, reusing the SAME popup locator since an exhaustive DAT sweep found `UIElement_SmartBoxWrapper` (class `0x10000030`) has no authored `ElementDesc` anywhere installed (`TooltipLiveDatTests.SmartBoxWrapper_HasNoAuthoredElementDesc_AnywhereInstalled`) — the popup-skin choice is therefore the best-evidenced inference, not a measured value, and is called out here as such. **BATCH B (2026-08-17) CLOSED the spellcasting and character-panel rows of this list. BATCH C (2026-08-17, Map/House toolbar panel) CLOSES THE LAST REMAINING ITEM: `gmMapUI::AddMapNote @0x004A1C51`'s 53 town-hotspot tooltips are now ported via `MapPageController.BuildTownMarkers` (`src/AcDream.App/UI/Layout/MapPageController.cs`), setting `UiButton.TooltipText` (retail's RUNTIME `m_TTText`/`SetTooltip` mechanism, not the DAT-authored `P0x49` path an earlier same-day cut mistakenly used and which never rendered live during verification); literal town names from `MapLocations.cs` (a verbatim port of `s_rgLocations`), not a DAT string-table lookup, matching `AddMapNote`'s own `StringInfo::SetLiteralValue` call. CORRECTED at the same-day morning gate round (user finding 3 — the retail screenshot's green hover highlight + special-font parchment tooltip): Batch C's "the town-marker template authors no locator of its own" claim was WRONG — the template (`0x100001F0` in `0x21000026`, `MapNoteLiveDatTests`) authors its OWN `P0x47=0x10000398`/`P0x48=0x21000041` (the fourth popup skin, whose incorporated text child `0x10000396` fonts `0x40000015` where the other three skins font `0x40000002`), a zero per-element delay `P0x50=0.0`, `P0x4B` TooltipOn, and `P0x13` RolloverEnabled with PassToChildren `Normal`/`Normal_rollover` states flipping the highlight child `0x100001F1`'s per-state `P0x3B` (the green `0x06004CC9` frame, byte-decoded A=FF R=00 G=FF B=00); the hardcoded shared-skin override was removed (the built marker's authored locator wins) and the rollover highlight + per-state-`P0x3B` + button PassToChildren cascade are now ported (`UiButton.CascadeStateToChildren`, `UiDatElement.TrySetRetailState`'s 0x3B honor). Sub-mechanism (1)'s `SetTooltip`-call-site enumeration is 16 of 17 known sites PORTED — `UIElement_Text::RecalculateTruncation @0x00466F80` (the headline, highest-volume site named at the top of sub-mechanism (1)) remains the ONE open item, exactly as this row's own sub-mechanism (1) text above already scoped it out (its own "Sized as genuinely disproportionate... deferred here" note). The prior "all 15 known sites accounted for" close (F12 correction, night-round review) was wrong twice over: the count is 17, not 15, and RecalculateTruncation was never actually ported — it was always the one deliberately-deferred item, not a closed one.** Batch B audit findings: the endowment icon `@0x004C63A1`, favorite `@0x004C7206`, and submenu `@0x004C67D8` sites turned out to be ALREADY CORRECT — all three are `UiCatalogSlot`-based and the pre-existing `Label`-driven `GetTooltipText` already carried retail's exact text (`SpellCastSubMenu::AddFavorite @0x004C7060`/`UpdateFromPlayerModule @0x004C6570` both build a single-arg `Formatted` PStringBase — plain spell name, no wrapper — for the favorite-bar/submenu case; `gmSpellcastingUI::UpdateEndowmentIcon @0x004C6120` mirrors the cast button's confirmed `"%s (%hs)"` literal at `data_7b64d8` — item name, then spell name in parens — via the identical two-value narrow/wide prep sequence, one address away in the same function family). The cast button `@0x004C6FE8`/`ClearTooltip @0x004C6AAE` was the one real gap (`UiButton` had no tooltip wiring at all): now ported via `SpellcastingUiController.UpdateCastAvailability`/`ComputeEndowmentCastState`, sourced from `gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30`. Fully verified literal text: the no-selection states (`"Select a spell to cast"` @ `data_7b64ec`, `"You have no spells ready to cast"` @ `data_7b6520`) and the complete endowment-item branch (`"USE the %s"` @ `data_7b64c0`, `" on %s"` @ `data_7b6464`, `"You must select a target for the %s"` @ `data_7b6478`; `ItemUses::IsUseable_SelfTarget @0x004fcd30` is exactly `ItemUseability.AllowsSelfTarget`). NOT ported: the endowment branch's incompatible-target sub-state (`"You must select an appropriate\ntarget for the %s"` @ `data_7b6400`, gated by `ItemHolder::TargetCompatibleWithObject @0x00587520` — a ~400-line function with its own chat-message side effects, out of scope for a tooltip batch; a present target is optimistically treated as compatible, same text as the confirmed-compatible case). **CORRECTED at the night-round review (F3/F4, 2026-08-17): the plain-spell branch's wording is NOT unrecoverable — the "genuine `gmNoticeHandler` vtable SLOTS" claim above was itself the artifact. `PStringBase::sprintf`'s second argument at those three call sites is a raw pushed literal (a plain `push 0x7bXXXX; call sprintf`); Binary Ninja's pseudo-C rendering of that operand as `&gmSpellcastingUI::\`vftable'.RecvNotice_XXX` was a spurious symbol match, not the true operand — a direct capstone disassembly of the raw bytes at `0x4c6e48`/`0x4c6ea4`/`0x4c6f18`/`0x4c6f5d` resolves the actual constants: `"CAST %hs"` @0x7b63a4 (untargeted/self-cast at `0x4c6f35`, and targeted+compatible at `0x4c6e57` — both enabled, the latter appending `" on %s"` @0x7b6464 with the target's name), `"You must select an appropriate target for %hs"` @0x7b6348 (targeted+incompatible, disabled), `"You must select a target for %hs"` @0x7b63b8 (no target, disabled); `%hs` is the spell's own name in all four call sites (`CSpellBase::InqName`, the same call `0x5bbee0` throughout). Now ported: `RuntimeSpellCastState.EvaluateCastGate` (the four-state gate) + `SpellcastingUiController.ComputeSpellCastState`. Also corrected the endowment branch's "USE the %s" operand: it was NOT the bare item name (F4) — the vararg to `"USE the %s"`/`"You must select a target for the %s"`/the still-unported incompatible-target string is the SAME composed `"%s (%hs)"` string (item name, spell name) built once at `@0x004c6bb6-ef` from format literal `data_7b64d8`, byte-confirmed by all three sprintf call sites (`0x4c6c7f`/`0x4c6ca4`/`0x4c6d46`) reading the identical `[esp+0x18]` slot — now ported via `SpellcastingUiController.ComposeEndowmentName`.** **#430 CORRECTION (2026-08-24): the character-panel port below set the TEXT but omitted the popup LOCATOR on the runtime-built rows, so these tooltips never mounted until the CA5-adjacent fix gave rows the shared 0x10000395/0x21000041 skin (live-DAT probed; UiItemSlot precedent).** The character-panel `AttributeInfoRegion @0x004F1617` / `Attribute2ndInfoRegion @0x004F1777` / `SkillInfoRegion @0x004F222F` constructors are now fully ported through a new `UiClickablePanel.TooltipText` (the same settable-string seam as `UiButton.TooltipText`): the six hardcoded attribute descriptions (`SkillSystem::InqAttributeDescription @0x005c8e30`) and three hardcoded, pair-shared vitals descriptions (`SkillSystem::InqAttribute2ndDescription @0x005c8f70`) were byte-decoded from the retail string pool (the pseudo-C dump truncates them with "…"); skill tooltips compose `SkillInfoRegion::GetTooltip @0x004f1fe0`'s exact `"\n" + formula + description` (ported verbatim, including the confirmed lack of any separator between the formula and description text) from the ALREADY-DAT-parsed `DatReaderWriter.Types.SkillBase.Description`/`.Formula` fields (portal `0x0E000004`, the same resource `CharacterSheetProvider.SkillTable` already reads for skill names/costs) rather than hand-transcribed literals — no guessing was needed for the ~30+ skill description strings. The formula-to-text algorithm itself (`SkillSystem::InqSkillFormula @0x005c89b0`, e.g. producing `"( (Strength + Coordination) / 2 )"`) was fully recovered by byte-decoding six short literal fragments (`data_7e7930`/`7e7934`/`7e7940`/`7e7950`/`7e7954`/`797584`) the pseudo-C dump left completely unlabeled — they sit between two `gmSpellcastingUI` vtable declarations and Binary Ninja's type inference never recognized them as strings, so the raw hex had to be read directly as narrow ASCII (confirmed against the function's own directly-visible `" / %u"` and `"(%u x %s)"` literals, which needed no such recovery). Retail's runtime sites also SET the `P0x4B` on-bit themselves (`__bitfield164 |= 0x20`, eight sites) — the port models that as "runtime text present implies tooltip-on", so only the authored-text path consults the authored bit. **(2) The per-element wrap-width override:** `UIElement_Text::InqSizewMargins @0x00469660`'s `UITS_MAX_WIDTH` branch checks `GetAttribute_Int(this, 0x3D, ...)` before falling back to `RenderDevice::GetDisplayWidth()`; `RetailTooltipPresenter.ApplyTooltipText` always wraps at `UiRoot.EffectiveCanvasSize.X` (the confirmed fallback) and never checks for a `P0x3D` override — the live-DAT sweep found zero tooltip-bearing elements author one. | `src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs` (`ResolveTooltipText`'s runtime-then-authored order; `ApplyTooltipText`'s wrap-width literal; `UpdateWorldHoverTooltip`/`TryBuildAndMountPopup` — the world-hover half added 2026-08-16); `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo.TooltipText`'s own doc comment carries the same F3 correction); `src/AcDream.App/UI/UiItemSlot.cs` (`TooltipTextResolve`, `GetTooltipText`, the hardcoded popup-locator constants); `src/AcDream.Core/Items/ClientObject.cs` (`GetTooltipDisplayName`); `src/AcDream.App/UI/CursorFeedbackController.cs` (the related #411 found-cursor fix, same round — see that row); Batch B (2026-08-17) additions: `src/AcDream.App/UI/UiPanel.cs` (`UiClickablePanel.TooltipText`/`GetTooltipText`); `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`UpdateCastAvailability`, `ComputeEndowmentCastState`); night-round review (F3/F4, 2026-08-17) additions: `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`ComputeSpellCastState`, `ComposeEndowmentName`); `src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs` (`EvaluateCastGate`, `SpellCastGate`); `src/AcDream.App/Net/RetailSkillFormula.cs` (`AttributeName`, `FormatFormula`, `BuildTooltip`); `src/AcDream.App/UI/Layout/CharacterSheet.cs` (`CharacterSkill.TooltipText`); `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`BuildLiveCharacterSkills`'s tooltip compose); `src/AcDream.App/UI/Layout/CharacterStatController.cs` (`AttributeDescriptions`, `Attribute2ndDescriptions`, `BuildAttributeRows`/`BuildSkillRows` row wiring) | The 243 elements WITH literal text — the "core" case #409 ships — cover every hover-text scenario the investigation's own landmark checks exercised (main-game-UI Appearance-page rotate/color/spin hints, etc.). F9 correction: "243 with literal text" is not automatically "243 showable" — `RetailTooltipPresenter.OnTooltipShow`'s real gate is the FULL conjunction of `TooltipEnabled` (P0x4B) AND non-null text (P0x49) AND both popup-locator ids (P0x47/P0x48) — so this was measured, not assumed: `TooltipLiveDatTests.ClientWideSweep_FindsKnownLandmarksAndAFloorCount`'s `Showable` column finds the intersection is exactly 243, i.e. every element authoring literal text also authors the other three properties together (they are evidently authored as one group in practice). The P0x3D sweep found zero authoring elements, so the unconditional display-width fallback is not an approximation for any element that exists today. | If a future DAT revision adds a `P0xD0`-truncated text element, a game-code `SetTooltip` caller, or authors a `P0x3D` override, it silently shows no tooltip / wraps at the wrong width instead of erroring — indistinguishable from "the element authors no tooltip at all" without re-running the sweep AND separately auditing which of the 187 no-literal-text elements would actually truncate at their authored width. | `UIElement::InqProperty @0x004638D0` (base authored-bag read, NOT a dynamic override); `UIElement::StartTooltipAtMouse @0x00460D70` (`m_TTText`-vs-`InqProperty` preference order); `UIElement_Text::RecalculateTruncation @0x00466F80` (`P0xD0` gate, `SetTooltip`/`ClearTooltip` sites); `UIElement_Text::InqSizewMargins @0x00469660` (`UITS_MAX_WIDTH` branch, `GetAttribute_Int(this, 0x3D, ...)`); Batch B (2026-08-17): `gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30` (cast-button state machine); `gmSpellcastingUI::UpdateEndowmentIcon @0x004C6120` (endowment-icon `"%s (%hs)"` confirmation); `SpellCastSubMenu::AddFavorite @0x004C7060` / `UpdateFromPlayerModule @0x004C6570` (favorite/submenu plain-name confirmation); `ItemUses::IsUseable_SelfTarget @0x004fcd30`; `AttributeInfoRegion::AttributeInfoRegion @0x004f1530` / `Attribute2ndInfoRegion::Attribute2ndInfoRegion @0x004f1680` / `SkillInfoRegion::SkillInfoRegion @0x004f2140` / `SkillInfoRegion::GetTooltip @0x004f1fe0`; `SkillSystem::InqAttributeName @0x005c8d90` / `InqAttributeDescription @0x005c8e30` / `InqAttribute2ndName @0x005c8ed0` / `InqAttribute2ndDescription @0x005c8f70` / `InqSkillFormula @0x005c89b0` | | TS-84 | Chargen 3D preview (Campaign CC slice CC6a foundation): `ChargenClothingTable`'s composer skips retail's ~8-branch Setup-id substitution chain (`ClothingTable::BuildObjDesc @ 0x005A7900`'s Umbraen/Penumbraen/Undead/Anakshay fallback) when a garment's `ClothingBaseEffects` has no entry for the resolved body Setup. MEASURED (not assumed) against the installed EoR dat across all 26 heritage/gender combinations via `ChargenAppearanceCatalogInstalledDatTests`, with the measurement now PINNED by a real assertion rather than diagnostic-only output (review fix round F7): the 9 standard heritages whose UI actually shows clothing controls resolve every default gear choice with zero coverage gaps. Undead is a real gap — its default gear choices (both genders) have NO base-effect entry on **ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear** (not the three-slot "headgear/trousers/footwear" this row originally understated, with a self-contradicting "4 of 4 non-shirt slots" aside — corrected at the review fix round F2) — for Undead's own live body Setup (male 0x02001A9C / female 0x02001AA0), because that Setup is one of the skeleton/zombie variants the un-ported chain exists to redirect. The four measured missing clothing-table ids are identical on both genders and in a fixed order: `0x10000009, 0x100000F9, 0x10000001, 0x10000007` (Headgear, Trousers, Shirt, Footwear — the factory's own composition order). Gear Knight and both Olthoi variants also show gaps under a synthetic "select every offered option" sweep, but retail hides the clothing controls entirely for those three heritages (`gmCGAppearancePage::Update @ 0x0047E8F0`'s `m_pClothesButton->SetVisible(0)` branches for `mHeritageGroup == 6` and `== 0xc \|\| == 0xd`), so a real chargen selection never reaches them — not a live gap. | `src/AcDream.Core/CharGen/ChargenClothingTable.cs`; `src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs` (`ComposeClothingSlot`) | CC6a is explicitly the rendering-foundation slice (index→ObjDesc factory + static-pose offscreen renderer, no page mount yet); porting the ~8-branch substitution chain is bounded follow-up work once CC6b wires real clothing-slot UI, not a blocker for the foundation deliverable — and the installed-DAT test proves the gap is narrow (one heritage, all four of ITS slots) rather than pervasive. | Undead's default clothing preview renders the bare body mesh for ALL FOUR slots — headgear, trousers, shirt, AND footwear (no clothing part/texture override applied on any of them, though the dye subpalette contribution — gated on a DIFFERENT lookup — is unaffected) — until the chain, or an equivalent per-heritage default-clothing-Setup map, is ported. | `ClothingTable::BuildObjDesc @ 0x005A7900` (Umbraen/Penumbraen/Undead/Anakshay Setup-substitution branches); `gmCGAppearancePage::Update @ 0x0047E8F0` (clothes-button visibility gate); `tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs` | | TS-73 | **NARROWED 2026-08-11 at Campaign OP slice OP4.** `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged @0x0059A8E0`'s local side-effect switch (step 2) still covers only the two `PlayerModule`-state-mutating cases (`case 2`/`case 0x12` fellowship mutual exclusion) — that part is unchanged. Of the four presentation-binding cases, TWO are now closed: `0x07 ViewCombatTarget` (re-pointed `ICombatGameplaySettingsSource` reads `RuntimeCharacterOptionsState` live — `CharacterOptionCombatSettingsSource`, `src/AcDream.App/Combat/LiveCombatAttackOperations.cs`) and `0x30 DisableDistanceFog` (`WeatherSystem.DisableDistanceFogSource`, a poll bound once in `GameWindow.cs`, forces `FogMode.Off` in `WeatherSystem.Snapshot`) — NEITHER lives inside `TrySetOption`'s own switch; both are separate App-layer poll bindings, so the literal claim in this row's title ("this Runtime-only seam can reach") stays true, but the user-observable symptom is fixed for these two ids. The remaining two, `0x04 DisableMostWeatherEffects` and `0x05 PersistentAtDay`, stay open — see TS-6 (weather-particle subsystem not yet located) and TS-75 (day/night force) respectively; this row no longer duplicates either. | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`RuntimeCharacterOptionsState.TrySetOption`) | The remaining two options are correctly scoped to their OWN pre-existing/new rows (TS-6, TS-75) rather than re-litigated here. | Toggling `DisableMostWeatherEffects`/`PersistentAtDay` writes the bit and dirties/auto-saves it correctly, but produces NONE of retail's immediate local presentation change (weather doesn't stop, day/night doesn't force) — see TS-6/TS-75 for why. `ViewCombatTarget`/`DisableDistanceFog` are retired from this row's risk: both now behave correctly. | `CPlayerModule::OnChanged @0x0059A8E0`; `docs/research/2026-08-10-character-options-map.md` §1.5 | | TS-75 | "Always Daylight Outdoors" (`PlayerOption PersistentAtDay`, `CPlayerModule::OnChanged` case `0x05` → `LScape::SetDay(value)`) has no acdream consumer. The campaign plan's own Group-B binding table cites `RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` as the target seam — **that citation is a mechanism mismatch, corrected here**: `ForcedDayGroupIndex` selects which WEATHER-VARIETY day-group (`RuntimeWorldDayGroupDefinition`, e.g. a Clear/Overcast/Rain/Snow/Storm pick) is always chosen — the SAME deterministic-per-day-RNG mechanism `WeatherSystem`'s own roll uses (see TS-6) — NOT retail's time-of-day day/night force. No acdream mechanism currently overrides the sky cycle's TIME to stay in daytime lighting; wiring this option correctly needs that mechanism built first, not just a poll into the wrong field. | `src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs` (`RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` — NOT the right target); no current consumer exists | Filed rather than silently wired to the wrong field — a poll into `ForcedDayGroupIndex` would have SILENTLY changed the character's weather-variety odds instead of forcing daytime, an incorrect fix masquerading as a correct one (CLAUDE.md's "no workarounds" rule). | Toggling the option writes the bit and dirties/auto-saves it correctly, but night still falls normally — no observable daylight-forcing behavior. | `CPlayerModule::OnChanged @0x0059A8E0` case 5; `LScape::SetDay` (not yet located in the decomp) | diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index d7d430d8..d29dfdc4 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -247,7 +247,17 @@ public static class CharacterStatController /// icon rendering is not asserted. /// /// - public static void Bind( + /// + /// #431-CA5 gate fix (2026-08-24): the data-changed refresh. Retail's + /// panel refreshes rows from qualities on every authoritative + /// quality-change element message (InfoRegion::OnQualityChanged @ + /// 0x004F0EB0 → the 0x10000004 broadcast); before this, rows only + /// rebuilt on CLICKS, so a trained skill stayed in the untrained + /// section (and CA4's awaiting-ghost never visually released) until the + /// next click. The caller invokes this from the sheet-changed + /// subscription. + /// + public static Action Bind( ImportedLayout layout, Func data, UiDatFont? datFont = null, @@ -601,6 +611,8 @@ public static class CharacterStatController RefreshActiveRaiseButtons(); } + + return () => RefreshAfterRaise(null); } private static UiScrollbar? PrepareSkillScrollbar( @@ -1502,6 +1514,16 @@ public static class CharacterStatController { var row = new UiClickablePanel { + // #430: runtime-built rows author no P0x47/P0x48 of their own, and + // OnTooltipShow refuses a widget without a popup locator — so the + // Batch-B TooltipText never mounted. Use the shared popup skin, + // the ONLY locator pair the character layout (0x2100002E) itself + // references (live-DAT probed 2026-08-24) and the same inference + // UiItemSlot already ships for runtime-built widgets. + AuthoredTooltipRootElementId = + RetailTooltipPresenter.SharedPopupSkinRootElementId, + AuthoredTooltipLayoutDid = + RetailTooltipPresenter.SharedPopupSkinLayoutDid, Left = left, Top = top, Width = width, diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 62b8c251..c259f2c3 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -4017,9 +4017,7 @@ public sealed class RetailUiRuntime : IDisposable } CharacterSheetProvider provider = _bindings.Character.Provider; CharacterSheet currentSheet = provider.BuildSheet(); - _characterSheetSubscription = provider.SubscribeChanged( - () => currentSheet = provider.BuildSheet()); - CharacterStatController.Bind( + Action refreshRows = CharacterStatController.Bind( layout, () => currentSheet, _bindings.Assets.DefaultFont, @@ -4027,6 +4025,16 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Assets.ResolveSprite, (request, completed) => HandleCharacterRaise(provider, request, completed), () => CloseWindow(WindowNames.Character)); + // #431-CA5 gate fix: rebuild the ROWS on every authoritative sheet + // change, not only on clicks — a train's skill record must move the + // row to the trained section (and un-ghost the raise controls) the + // moment it lands, mirroring retail's quality-change broadcast + // (InfoRegion::OnQualityChanged @ 0x004F0EB0). + _characterSheetSubscription = provider.SubscribeChanged(() => + { + currentSheet = provider.BuildSheet(); + refreshRows(); + }); RetailWindowHandle handle = RetailWindowFrame.Mount( Host.Root, layout.Root, diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 430452f6..a62590b0 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -883,6 +883,106 @@ public class CharacterStatControllerTests Assert.Equal(Vector4.One, healingTexts[2].LinesProvider()[0].Color); } + [Fact] + public void DataChangedRefresh_MovesATrainedSkillToItsSection_WithoutAClick() + { + // #431 CA5-gate bug: a TrainSkill's authoritative record updated the + // sheet, but rows only rebuilt on CLICKS — the skill sat in the + // untrained section (credits visibly debited) until the user clicked + // train AGAIN. Bind now returns the data-changed refresh; invoking + // it must re-bucket the rows, mirroring retail's quality-change + // broadcast (InfoRegion::OnQualityChanged @ 0x004F0EB0). + var list = new UiPanel { Width = 300 }; + var layout = Fake((CharacterStatController.ListBoxId, list)); + + CharacterSheet sheet = SampleData.SampleCharacter(); + Action refresh = CharacterStatController.Bind(layout, () => sheet, + spriteResolve: id => (id, 16, 16)); + + ClickTab(layout, left: 92f); + var untrained = sheet.Skills.First( + s => s.AdvancementClass == CharacterSkillAdvancementClass.Untrained); + int trainedBefore = sheet.Skills.Count( + s => s.AdvancementClass == CharacterSkillAdvancementClass.Trained); + Assert.Equal(trainedBefore, RowsUnderHeader(list, "Trained Skills")); + + // The server record lands: the skill is now Trained. CharacterSheet + // is a plain class (init-only), so rebuild it like the provider does. + CharacterSheet updated = SampleData.SampleCharacter(); + updated.GetType(); // (no-op guard for clarity) + sheet = new CharacterSheet + { + Name = sheet.Name, + UnassignedXp = sheet.UnassignedXp, + SkillCredits = sheet.SkillCredits, + Skills = sheet.Skills + .Select(s => s.Id == untrained.Id + ? s with { AdvancementClass = CharacterSkillAdvancementClass.Trained } + : s) + .ToList(), + }; + + refresh(); + + Assert.Equal(trainedBefore + 1, RowsUnderHeader(list, "Trained Skills")); + } + + [Fact] + public void Rows_CarryTheSharedTooltipPopupLocatorAndDescriptionText() + { + // #430: runtime-built rows author no popup locator, and the tooltip + // presenter refuses a widget without one — so the row descriptions + // could never mount. Rows must carry the shared popup skin (the only + // locator pair the character layout itself references; the same + // inference UiItemSlot ships) alongside their runtime text. + var list = new UiPanel { Width = 300 }; + var layout = Fake((CharacterStatController.ListBoxId, list)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter, + spriteResolve: id => (id, 16, 16)); + + var attributeRows = list.Children.OfType().ToList(); + Assert.NotEmpty(attributeRows); + Assert.All(attributeRows, row => + { + Assert.Equal( + RetailTooltipPresenter.SharedPopupSkinRootElementId, + row.AuthoredTooltipRootElementId); + Assert.Equal( + RetailTooltipPresenter.SharedPopupSkinLayoutDid, + row.AuthoredTooltipLayoutDid); + }); + // The six attribute rows carry the retail descriptions. + Assert.All( + attributeRows.Take(6), + row => Assert.False(string.IsNullOrEmpty(row.GetTooltipText()))); + } + + private static int RowsUnderHeader(UiElement list, string header) + { + // The rows live inside a nested content container; find the + // container whose ordered children interleave section headers and + // rows, then count rows between the named header and the next one. + static bool IsHeader(UiElement c, string text) => + c is UiPanel and not UiClickablePanel + && c.Children.OfType().Any( + x => x.LinesProvider()[0].Text == text); + UiElement? container = new[] { list }.Concat(Descendants(list)) + .FirstOrDefault(e => e.Children.Any(c => IsHeader(c, header))); + Assert.NotNull(container); + var children = container!.Children.ToList(); + int start = children.FindIndex(c => IsHeader(c, header)); + Assert.True(start >= 0, $"header '{header}' not found"); + int count = 0; + for (int i = start + 1; i < children.Count; i++) + { + if (children[i] is UiClickablePanel) count++; + else if (children[i] is UiPanel p + && p.Children.OfType().Any()) break; + } + return count; + } + [Fact] public void SkillsTab_ClickThenAttributesTab_RestoresAttributeRows() { From 51a7c99b94adc56b4084d4fef7f427d08e16c6f5 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 16:52:17 +0200 Subject: [PATCH 22/89] =?UTF-8?q?docs:=20CA5=20ledger=20=E2=80=94=20first?= =?UTF-8?q?=20drive=20partial=20results=20and=20the=20fix=20round?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-24-character-advancement-campaign.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-24-character-advancement-campaign.md b/docs/plans/2026-08-24-character-advancement-campaign.md index 35eacb65..d9e5e040 100644 --- a/docs/plans/2026-08-24-character-advancement-campaign.md +++ b/docs/plans/2026-08-24-character-advancement-campaign.md @@ -123,4 +123,4 @@ neighborhood). | CA2 | COMPLETE 2026-08-24 (`65430d4c`) | 0x02E3/0x02DD parsers + WorldSession events + router routing into LocalPlayerState; conformance tests incl. holtburger golden fixture; 0x02DF deliberately unparsed (no ACE producer) | | CA3 | COMPLETE 2026-08-24 | Live formula recompute (SkillFormulaBonusResolver over RetailSkillFormula) on attribute writes + fresh-train derivation; movement re-applied down the PD seam (PushMovementSkillTotals — Quickness raise → run speed, no relog); vitals bar pull-model verified; router behavior + fresh-train tests | | CA4 | COMPLETE 2026-08-24 | Optimistic ApplyLocalRaise layer DELETED; retail one-in-flight + ghost + server-authoritative flow ported per the pinned §5 pseudocode (AP-73 NARROWED — rejection-release semantics owed to CA5 live); train cost verified DAT-exact; specialize correctly has no panel send (gem + confirmation route, seams already present); provider contract tests rewritten | -| CA5 | SCRIPT WRITTEN 2026-08-24 — awaiting the owner drive | docs/research/2026-08-24-campaign-ca-test-script.md (scratch character; §3.2 resolves the narrowed AP-73) | +| CA5 | FIRST DRIVE 2026-08-24 (partial) + fix round `bce17b3c` | §1 PASSED from the log (run total 40→433 live, each raise re-applied; zero exceptions). Drive harvested #440 (trained row stuck until next click — row refresh now driven by the authoritative record) and closed #430 (tooltips could never mount — rows lacked the popup locator). OWED at the next drive: §2 Endurance→stamina fan-out, §3.2 AP-73 ghost probe, §5/§6 re-check (train row moves immediately now), tooltip hover-dwell visual, and the three feel answers (run speed, advancement chat lines, ghost cycle). | From 1e8596a4401572f81c525a568ea21a212fcb3671 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 18:09:54 +0200 Subject: [PATCH 23/89] =?UTF-8?q?fix(ui):=20retail=20tooltip=20rendering?= =?UTF-8?q?=20=E2=80=94=20formula-first=20compose=20and=20the=20two-pass?= =?UTF-8?q?=20sizing=20every=20tooltip=20was=20missing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections from the owner's retail-render oracle at the CA5 re-check, both against readings the TS-85 register row had recorded as settled: Compose (skill tooltips): retail is formula + newline + description — GetTooltip @0x004f1fe0's operator+ has the InqSkillFormula output as the LEFT operand; the old '"\n" + formula, no separator' reading had the operand order backwards and produced a leading blank line with the formula and description glued on one line. A formula-less skill (Salvaging) shows the bare description, matching the failed-InqSkillFormula branch. Sizing (ALL tooltips, per the owner's direction): retail sizes a tooltip in TWO passes (StartTooltip @0x0045DE90) — measure-wrap at the max width, resize the root through the authored ResizeTo clamps, then RecalculateGlyphList RE-WRAPS the text at its final clamped width and a second resize grows the root's HEIGHT for the extra lines. The branch the register called 'a structural no-op' IS that second pass; without it a description longer than the clamped popup stayed one clipped line, where retail shows three. ApplyTooltipText now ports the full chain, so every tooltip surface (items, options rows, character panel, world hover, map) wraps and grows exactly as retail. Pinned by BuildTooltip_FormulaFirstThenNewlineThenDescription, BuildTooltip_FormulaLessSkillShowsBareDescription, and LongTooltip_RewrapsAtTheClampedPopupWidth_AndGrowsHeightForTheExtraLines. TS-85 carries both dated corrections. Owner visual re-check owed: skill tooltip shows formula on line one, description below, long descriptions wrapping to three-plus lines inside the parchment. Full hermetic suite 15,332 passed / 0 failed. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 4 +- src/AcDream.App/Net/RetailSkillFormula.cs | 21 ++++--- .../UI/Layout/RetailTooltipPresenter.cs | 62 +++++++++++++++--- .../Net/RetailSkillFormulaTests.cs | 42 +++++++++++++ .../UI/Layout/RetailTooltipPresenterTests.cs | 63 +++++++++++++++++++ 5 files changed, 174 insertions(+), 18 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index e0942e31..8f828cb9 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -423,7 +423,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| -| TS-85 | **Filed 2026-08-16 at #409 (client-wide retail tooltip system); REWRITTEN at the same-day F3 review round; NARROWED again at the same-day live-failure round.** LIVE-FAILURE-ROUND NARROWING: the `m_TTText` READ side is now ported — `RetailTooltipPresenter.ResolveTooltipText` consults `UiElement.GetTooltipText()` (this port's `m_TTText`) BEFORE the authored `P0x49`, exactly as `StartTooltipAtMouse @0x00460DA3`/`@0x00460DDF` orders them, and the `P0x48`-absent fallback to the element's own layout (`@0x00460E7E`) is ported through `UiElement.SourceLayoutDid`. That lit up every acdream surface whose controller ALREADY writes runtime tooltip text (the four Options tabs, Configure Keyboard, the social pages) — live-verified 2026-08-16 on the Character tab. What remains deferred is the WRITE side at the retail `SetTooltip` call sites acdream has no analog for yet, enumerated below. Two sub-mechanisms of retail's tooltip system are unported. **(1) The `m_TTText`/`SetTooltip` runtime-text family (headed by the `P0xD0` truncated-text auto-tooltip):** the ORIGINAL filing argued this port's gap was "retail's dynamic `InqProperty(0x49)` override" — that framing is false. `UIElement::InqProperty @0x004638D0`, the BASE implementation every element uses unless its own class overrides the virtual, reads exactly the same authored property bags (`m_instanceProperties`, `m_curStateDesc`, `m_desc`) this port's `ElementReader` already walks generically — so an element with no literal `P0x49` gets NOTHING from retail's own default `InqProperty` either. The REAL second text source is the element's cached `m_TTText` field, set ONLY by the explicit, non-dat `UIElement::SetTooltip` call (`UIElement::StartTooltipAtMouse @0x00460D70` prefers `m_TTText` over the `InqProperty` fallback whenever it is non-empty). `SetTooltip` has 15+ known game-code call sites (Options rows `@0x00485E65`, chargen `@0x00481981`, the paperdoll endowment icon `@0x004C63A1`, the spellcast button `@0x004C6FE8`/`@0x004C6AAE`, and more), headed by the highest-volume one: `UIElement_Text::RecalculateTruncation @0x00466F80`, gated on authored `P0xD0` — an overflowing single/wrapped line calls `SetTooltip(this, ownText) @0x00467064` + sets enable bit 5 `@0x00467076`; a line that now fits calls `ClearTooltip @0x00467064`/clears the bit `@0x00466ff9`. `RecalculateTruncation`'s own truncation-POSITION computation (the rest of the function, `@0x004670a1` onward) walks a `GlyphList` per-line-position model (`FindCompleteLineFromY`/`FindPosFromLineAndPixels`/`FindPixelsFromPos`) this port's `UiText` has no equivalent of — `UiText` clips visually via a scissor rect (`DrawClippedText`'s `PushClip`) with no tracked "does this line overflow" state at all, so porting the auto-tooltip trigger requires building that state first. Sized as genuinely disproportionate for a single fix-round commit alongside F1-F2/F4-F11 and deferred here rather than shipped as a partial/unverified stub. A live-DAT sweep found 187 of the 430 elements authoring at least one tooltip-trigger property have NO literal `P0x49` `StringInfo` text; the live-failure round re-measured that set and found every one of the 187 authors BOTH popup-locator ids (`P0x47`+`P0x48`) — i.e. they are runtime-`SetTooltip` targets by construction, waiting only for text. **F12 correction (night-round review, 2026-08-17): this is 17 sites, not 15** — the original tally dropped `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` (mentioned two sentences below as its own closed row) and undercounted by one more besides. The 17 `SetTooltip` call sites, enumerated from the decomp at the live-failure round, split into: PORTED (an acdream controller already writes the text, and the presenter now reads it) — the Options rows `@0x00485E65`/`@0x00484803`/`@0x00487053`, chargen skills `@0x00481981`, the radar `@0x004D9605`; PORTED 2026-08-16 (hover-feedback completion round, docs/ISSUES.md #409/#411): inventory/shortcut item hover `UIElement_UIItem::UpdateTooltip @0x004E1CB0` — `UiItemSlot` now hardcodes the catalog's uniform popup locator (`P0x47=0x10000395`/`P0x48=0x21000041`, live-DAT-confirmed uniform across all 47 UIItem-type catalog prototypes, `TooltipLiveDatTests.UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator`) and a `TooltipTextResolve` delegate wired at every physical-item construction site (inventory, external container, paperdoll — closing the separate `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` row below too, vendor, secure trade, toolbar), backed by the new `ClientObject.GetTooltipDisplayName()` (NAME_APPROPRIATE + the `"%d %s"` stack-count prefix, matching the decomp exactly); and the SmartBox found-object world-hover tooltip `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004E5AD0` (`@0x004E5D74`/`@0x004E5DFB`) — `RetailTooltipPresenter.UpdateWorldHoverTooltip` ports its `PlayerModule::ShowTooltips`-gated, `GetAppropriateName`-only (no stack prefix — a real, decomp-confirmed asymmetry vs. the item-cell case) trigger — **TIMING CORRECTED at the 2026-08-17 morning gate round (user finding: retail world tooltips "lag"; ours popped instantly): the original "edge-fired (no dwell)" reading was a misread — the notice's immediate `StartTooltipAtMouse @0x004E5DFB` sits inside `if (s_pInstance->m_dragElement != 0)` (`@0x004E5D8E`; `m_dragElement` is a real, distinct PDB field in `acclient.h`'s `UIElementManager`, separate from the `m_pTooltipElement` family), so the immediate mount is DRAG-ONLY; the ordinary hover path merely STAGES the name (`SetTooltip @0x004E5D74` + `|= 0x20`) and the display rides `CheckTooltip @0x0045B6E0`'s mouse-idle dwell (`m_lastMouseMoveTime` stamped on EVERY move `@0x0045e736` + `m_tooltipDelay` 0.25 s `@0x0045f75d`), with found-object changes under an idle mouse swapping the popup same-frame via `SetTooltip`'s own text-change teardown (`@0x004617FF` → `ResetTooltip @0x0045C360` tail-calling `CheckTooltip`) and the 10 s `m_tooltipDuration` expiry requiring a fresh mouse move before re-arming (`SwitchMouseOver(null) @0x0045b7b2`) — all now ported, including the drag-immediate branch**, reusing the SAME popup locator since an exhaustive DAT sweep found `UIElement_SmartBoxWrapper` (class `0x10000030`) has no authored `ElementDesc` anywhere installed (`TooltipLiveDatTests.SmartBoxWrapper_HasNoAuthoredElementDesc_AnywhereInstalled`) — the popup-skin choice is therefore the best-evidenced inference, not a measured value, and is called out here as such. **BATCH B (2026-08-17) CLOSED the spellcasting and character-panel rows of this list. BATCH C (2026-08-17, Map/House toolbar panel) CLOSES THE LAST REMAINING ITEM: `gmMapUI::AddMapNote @0x004A1C51`'s 53 town-hotspot tooltips are now ported via `MapPageController.BuildTownMarkers` (`src/AcDream.App/UI/Layout/MapPageController.cs`), setting `UiButton.TooltipText` (retail's RUNTIME `m_TTText`/`SetTooltip` mechanism, not the DAT-authored `P0x49` path an earlier same-day cut mistakenly used and which never rendered live during verification); literal town names from `MapLocations.cs` (a verbatim port of `s_rgLocations`), not a DAT string-table lookup, matching `AddMapNote`'s own `StringInfo::SetLiteralValue` call. CORRECTED at the same-day morning gate round (user finding 3 — the retail screenshot's green hover highlight + special-font parchment tooltip): Batch C's "the town-marker template authors no locator of its own" claim was WRONG — the template (`0x100001F0` in `0x21000026`, `MapNoteLiveDatTests`) authors its OWN `P0x47=0x10000398`/`P0x48=0x21000041` (the fourth popup skin, whose incorporated text child `0x10000396` fonts `0x40000015` where the other three skins font `0x40000002`), a zero per-element delay `P0x50=0.0`, `P0x4B` TooltipOn, and `P0x13` RolloverEnabled with PassToChildren `Normal`/`Normal_rollover` states flipping the highlight child `0x100001F1`'s per-state `P0x3B` (the green `0x06004CC9` frame, byte-decoded A=FF R=00 G=FF B=00); the hardcoded shared-skin override was removed (the built marker's authored locator wins) and the rollover highlight + per-state-`P0x3B` + button PassToChildren cascade are now ported (`UiButton.CascadeStateToChildren`, `UiDatElement.TrySetRetailState`'s 0x3B honor). Sub-mechanism (1)'s `SetTooltip`-call-site enumeration is 16 of 17 known sites PORTED — `UIElement_Text::RecalculateTruncation @0x00466F80` (the headline, highest-volume site named at the top of sub-mechanism (1)) remains the ONE open item, exactly as this row's own sub-mechanism (1) text above already scoped it out (its own "Sized as genuinely disproportionate... deferred here" note). The prior "all 15 known sites accounted for" close (F12 correction, night-round review) was wrong twice over: the count is 17, not 15, and RecalculateTruncation was never actually ported — it was always the one deliberately-deferred item, not a closed one.** Batch B audit findings: the endowment icon `@0x004C63A1`, favorite `@0x004C7206`, and submenu `@0x004C67D8` sites turned out to be ALREADY CORRECT — all three are `UiCatalogSlot`-based and the pre-existing `Label`-driven `GetTooltipText` already carried retail's exact text (`SpellCastSubMenu::AddFavorite @0x004C7060`/`UpdateFromPlayerModule @0x004C6570` both build a single-arg `Formatted` PStringBase — plain spell name, no wrapper — for the favorite-bar/submenu case; `gmSpellcastingUI::UpdateEndowmentIcon @0x004C6120` mirrors the cast button's confirmed `"%s (%hs)"` literal at `data_7b64d8` — item name, then spell name in parens — via the identical two-value narrow/wide prep sequence, one address away in the same function family). The cast button `@0x004C6FE8`/`ClearTooltip @0x004C6AAE` was the one real gap (`UiButton` had no tooltip wiring at all): now ported via `SpellcastingUiController.UpdateCastAvailability`/`ComputeEndowmentCastState`, sourced from `gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30`. Fully verified literal text: the no-selection states (`"Select a spell to cast"` @ `data_7b64ec`, `"You have no spells ready to cast"` @ `data_7b6520`) and the complete endowment-item branch (`"USE the %s"` @ `data_7b64c0`, `" on %s"` @ `data_7b6464`, `"You must select a target for the %s"` @ `data_7b6478`; `ItemUses::IsUseable_SelfTarget @0x004fcd30` is exactly `ItemUseability.AllowsSelfTarget`). NOT ported: the endowment branch's incompatible-target sub-state (`"You must select an appropriate\ntarget for the %s"` @ `data_7b6400`, gated by `ItemHolder::TargetCompatibleWithObject @0x00587520` — a ~400-line function with its own chat-message side effects, out of scope for a tooltip batch; a present target is optimistically treated as compatible, same text as the confirmed-compatible case). **CORRECTED at the night-round review (F3/F4, 2026-08-17): the plain-spell branch's wording is NOT unrecoverable — the "genuine `gmNoticeHandler` vtable SLOTS" claim above was itself the artifact. `PStringBase::sprintf`'s second argument at those three call sites is a raw pushed literal (a plain `push 0x7bXXXX; call sprintf`); Binary Ninja's pseudo-C rendering of that operand as `&gmSpellcastingUI::\`vftable'.RecvNotice_XXX` was a spurious symbol match, not the true operand — a direct capstone disassembly of the raw bytes at `0x4c6e48`/`0x4c6ea4`/`0x4c6f18`/`0x4c6f5d` resolves the actual constants: `"CAST %hs"` @0x7b63a4 (untargeted/self-cast at `0x4c6f35`, and targeted+compatible at `0x4c6e57` — both enabled, the latter appending `" on %s"` @0x7b6464 with the target's name), `"You must select an appropriate target for %hs"` @0x7b6348 (targeted+incompatible, disabled), `"You must select a target for %hs"` @0x7b63b8 (no target, disabled); `%hs` is the spell's own name in all four call sites (`CSpellBase::InqName`, the same call `0x5bbee0` throughout). Now ported: `RuntimeSpellCastState.EvaluateCastGate` (the four-state gate) + `SpellcastingUiController.ComputeSpellCastState`. Also corrected the endowment branch's "USE the %s" operand: it was NOT the bare item name (F4) — the vararg to `"USE the %s"`/`"You must select a target for the %s"`/the still-unported incompatible-target string is the SAME composed `"%s (%hs)"` string (item name, spell name) built once at `@0x004c6bb6-ef` from format literal `data_7b64d8`, byte-confirmed by all three sprintf call sites (`0x4c6c7f`/`0x4c6ca4`/`0x4c6d46`) reading the identical `[esp+0x18]` slot — now ported via `SpellcastingUiController.ComposeEndowmentName`.** **#430 CORRECTION (2026-08-24): the character-panel port below set the TEXT but omitted the popup LOCATOR on the runtime-built rows, so these tooltips never mounted until the CA5-adjacent fix gave rows the shared 0x10000395/0x21000041 skin (live-DAT probed; UiItemSlot precedent).** The character-panel `AttributeInfoRegion @0x004F1617` / `Attribute2ndInfoRegion @0x004F1777` / `SkillInfoRegion @0x004F222F` constructors are now fully ported through a new `UiClickablePanel.TooltipText` (the same settable-string seam as `UiButton.TooltipText`): the six hardcoded attribute descriptions (`SkillSystem::InqAttributeDescription @0x005c8e30`) and three hardcoded, pair-shared vitals descriptions (`SkillSystem::InqAttribute2ndDescription @0x005c8f70`) were byte-decoded from the retail string pool (the pseudo-C dump truncates them with "…"); skill tooltips compose `SkillInfoRegion::GetTooltip @0x004f1fe0`'s exact `"\n" + formula + description` (ported verbatim, including the confirmed lack of any separator between the formula and description text) from the ALREADY-DAT-parsed `DatReaderWriter.Types.SkillBase.Description`/`.Formula` fields (portal `0x0E000004`, the same resource `CharacterSheetProvider.SkillTable` already reads for skill names/costs) rather than hand-transcribed literals — no guessing was needed for the ~30+ skill description strings. The formula-to-text algorithm itself (`SkillSystem::InqSkillFormula @0x005c89b0`, e.g. producing `"( (Strength + Coordination) / 2 )"`) was fully recovered by byte-decoding six short literal fragments (`data_7e7930`/`7e7934`/`7e7940`/`7e7950`/`7e7954`/`797584`) the pseudo-C dump left completely unlabeled — they sit between two `gmSpellcastingUI` vtable declarations and Binary Ninja's type inference never recognized them as strings, so the raw hex had to be read directly as narrow ASCII (confirmed against the function's own directly-visible `" / %u"` and `"(%u x %s)"` literals, which needed no such recovery). Retail's runtime sites also SET the `P0x4B` on-bit themselves (`__bitfield164 |= 0x20`, eight sites) — the port models that as "runtime text present implies tooltip-on", so only the authored-text path consults the authored bit. **(2) The per-element wrap-width override:** `UIElement_Text::InqSizewMargins @0x00469660`'s `UITS_MAX_WIDTH` branch checks `GetAttribute_Int(this, 0x3D, ...)` before falling back to `RenderDevice::GetDisplayWidth()`; `RetailTooltipPresenter.ApplyTooltipText` always wraps at `UiRoot.EffectiveCanvasSize.X` (the confirmed fallback) and never checks for a `P0x3D` override — the live-DAT sweep found zero tooltip-bearing elements author one. | `src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs` (`ResolveTooltipText`'s runtime-then-authored order; `ApplyTooltipText`'s wrap-width literal; `UpdateWorldHoverTooltip`/`TryBuildAndMountPopup` — the world-hover half added 2026-08-16); `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo.TooltipText`'s own doc comment carries the same F3 correction); `src/AcDream.App/UI/UiItemSlot.cs` (`TooltipTextResolve`, `GetTooltipText`, the hardcoded popup-locator constants); `src/AcDream.Core/Items/ClientObject.cs` (`GetTooltipDisplayName`); `src/AcDream.App/UI/CursorFeedbackController.cs` (the related #411 found-cursor fix, same round — see that row); Batch B (2026-08-17) additions: `src/AcDream.App/UI/UiPanel.cs` (`UiClickablePanel.TooltipText`/`GetTooltipText`); `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`UpdateCastAvailability`, `ComputeEndowmentCastState`); night-round review (F3/F4, 2026-08-17) additions: `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`ComputeSpellCastState`, `ComposeEndowmentName`); `src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs` (`EvaluateCastGate`, `SpellCastGate`); `src/AcDream.App/Net/RetailSkillFormula.cs` (`AttributeName`, `FormatFormula`, `BuildTooltip`); `src/AcDream.App/UI/Layout/CharacterSheet.cs` (`CharacterSkill.TooltipText`); `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`BuildLiveCharacterSkills`'s tooltip compose); `src/AcDream.App/UI/Layout/CharacterStatController.cs` (`AttributeDescriptions`, `Attribute2ndDescriptions`, `BuildAttributeRows`/`BuildSkillRows` row wiring) | The 243 elements WITH literal text — the "core" case #409 ships — cover every hover-text scenario the investigation's own landmark checks exercised (main-game-UI Appearance-page rotate/color/spin hints, etc.). F9 correction: "243 with literal text" is not automatically "243 showable" — `RetailTooltipPresenter.OnTooltipShow`'s real gate is the FULL conjunction of `TooltipEnabled` (P0x4B) AND non-null text (P0x49) AND both popup-locator ids (P0x47/P0x48) — so this was measured, not assumed: `TooltipLiveDatTests.ClientWideSweep_FindsKnownLandmarksAndAFloorCount`'s `Showable` column finds the intersection is exactly 243, i.e. every element authoring literal text also authors the other three properties together (they are evidently authored as one group in practice). The P0x3D sweep found zero authoring elements, so the unconditional display-width fallback is not an approximation for any element that exists today. | If a future DAT revision adds a `P0xD0`-truncated text element, a game-code `SetTooltip` caller, or authors a `P0x3D` override, it silently shows no tooltip / wraps at the wrong width instead of erroring — indistinguishable from "the element authors no tooltip at all" without re-running the sweep AND separately auditing which of the 187 no-literal-text elements would actually truncate at their authored width. | `UIElement::InqProperty @0x004638D0` (base authored-bag read, NOT a dynamic override); `UIElement::StartTooltipAtMouse @0x00460D70` (`m_TTText`-vs-`InqProperty` preference order); `UIElement_Text::RecalculateTruncation @0x00466F80` (`P0xD0` gate, `SetTooltip`/`ClearTooltip` sites); `UIElement_Text::InqSizewMargins @0x00469660` (`UITS_MAX_WIDTH` branch, `GetAttribute_Int(this, 0x3D, ...)`); Batch B (2026-08-17): `gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30` (cast-button state machine); `gmSpellcastingUI::UpdateEndowmentIcon @0x004C6120` (endowment-icon `"%s (%hs)"` confirmation); `SpellCastSubMenu::AddFavorite @0x004C7060` / `UpdateFromPlayerModule @0x004C6570` (favorite/submenu plain-name confirmation); `ItemUses::IsUseable_SelfTarget @0x004fcd30`; `AttributeInfoRegion::AttributeInfoRegion @0x004f1530` / `Attribute2ndInfoRegion::Attribute2ndInfoRegion @0x004f1680` / `SkillInfoRegion::SkillInfoRegion @0x004f2140` / `SkillInfoRegion::GetTooltip @0x004f1fe0`; `SkillSystem::InqAttributeName @0x005c8d90` / `InqAttributeDescription @0x005c8e30` / `InqAttribute2ndName @0x005c8ed0` / `InqAttribute2ndDescription @0x005c8f70` / `InqSkillFormula @0x005c89b0` | +| TS-85 | **Filed 2026-08-16 at #409 (client-wide retail tooltip system); REWRITTEN at the same-day F3 review round; NARROWED again at the same-day live-failure round.** LIVE-FAILURE-ROUND NARROWING: the `m_TTText` READ side is now ported — `RetailTooltipPresenter.ResolveTooltipText` consults `UiElement.GetTooltipText()` (this port's `m_TTText`) BEFORE the authored `P0x49`, exactly as `StartTooltipAtMouse @0x00460DA3`/`@0x00460DDF` orders them, and the `P0x48`-absent fallback to the element's own layout (`@0x00460E7E`) is ported through `UiElement.SourceLayoutDid`. That lit up every acdream surface whose controller ALREADY writes runtime tooltip text (the four Options tabs, Configure Keyboard, the social pages) — live-verified 2026-08-16 on the Character tab. What remains deferred is the WRITE side at the retail `SetTooltip` call sites acdream has no analog for yet, enumerated below. Two sub-mechanisms of retail's tooltip system are unported. **(1) The `m_TTText`/`SetTooltip` runtime-text family (headed by the `P0xD0` truncated-text auto-tooltip):** the ORIGINAL filing argued this port's gap was "retail's dynamic `InqProperty(0x49)` override" — that framing is false. `UIElement::InqProperty @0x004638D0`, the BASE implementation every element uses unless its own class overrides the virtual, reads exactly the same authored property bags (`m_instanceProperties`, `m_curStateDesc`, `m_desc`) this port's `ElementReader` already walks generically — so an element with no literal `P0x49` gets NOTHING from retail's own default `InqProperty` either. The REAL second text source is the element's cached `m_TTText` field, set ONLY by the explicit, non-dat `UIElement::SetTooltip` call (`UIElement::StartTooltipAtMouse @0x00460D70` prefers `m_TTText` over the `InqProperty` fallback whenever it is non-empty). `SetTooltip` has 15+ known game-code call sites (Options rows `@0x00485E65`, chargen `@0x00481981`, the paperdoll endowment icon `@0x004C63A1`, the spellcast button `@0x004C6FE8`/`@0x004C6AAE`, and more), headed by the highest-volume one: `UIElement_Text::RecalculateTruncation @0x00466F80`, gated on authored `P0xD0` — an overflowing single/wrapped line calls `SetTooltip(this, ownText) @0x00467064` + sets enable bit 5 `@0x00467076`; a line that now fits calls `ClearTooltip @0x00467064`/clears the bit `@0x00466ff9`. `RecalculateTruncation`'s own truncation-POSITION computation (the rest of the function, `@0x004670a1` onward) walks a `GlyphList` per-line-position model (`FindCompleteLineFromY`/`FindPosFromLineAndPixels`/`FindPixelsFromPos`) this port's `UiText` has no equivalent of — `UiText` clips visually via a scissor rect (`DrawClippedText`'s `PushClip`) with no tracked "does this line overflow" state at all, so porting the auto-tooltip trigger requires building that state first. Sized as genuinely disproportionate for a single fix-round commit alongside F1-F2/F4-F11 and deferred here rather than shipped as a partial/unverified stub. A live-DAT sweep found 187 of the 430 elements authoring at least one tooltip-trigger property have NO literal `P0x49` `StringInfo` text; the live-failure round re-measured that set and found every one of the 187 authors BOTH popup-locator ids (`P0x47`+`P0x48`) — i.e. they are runtime-`SetTooltip` targets by construction, waiting only for text. **F12 correction (night-round review, 2026-08-17): this is 17 sites, not 15** — the original tally dropped `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` (mentioned two sentences below as its own closed row) and undercounted by one more besides. The 17 `SetTooltip` call sites, enumerated from the decomp at the live-failure round, split into: PORTED (an acdream controller already writes the text, and the presenter now reads it) — the Options rows `@0x00485E65`/`@0x00484803`/`@0x00487053`, chargen skills `@0x00481981`, the radar `@0x004D9605`; PORTED 2026-08-16 (hover-feedback completion round, docs/ISSUES.md #409/#411): inventory/shortcut item hover `UIElement_UIItem::UpdateTooltip @0x004E1CB0` — `UiItemSlot` now hardcodes the catalog's uniform popup locator (`P0x47=0x10000395`/`P0x48=0x21000041`, live-DAT-confirmed uniform across all 47 UIItem-type catalog prototypes, `TooltipLiveDatTests.UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator`) and a `TooltipTextResolve` delegate wired at every physical-item construction site (inventory, external container, paperdoll — closing the separate `gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` row below too, vendor, secure trade, toolbar), backed by the new `ClientObject.GetTooltipDisplayName()` (NAME_APPROPRIATE + the `"%d %s"` stack-count prefix, matching the decomp exactly); and the SmartBox found-object world-hover tooltip `UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004E5AD0` (`@0x004E5D74`/`@0x004E5DFB`) — `RetailTooltipPresenter.UpdateWorldHoverTooltip` ports its `PlayerModule::ShowTooltips`-gated, `GetAppropriateName`-only (no stack prefix — a real, decomp-confirmed asymmetry vs. the item-cell case) trigger — **TIMING CORRECTED at the 2026-08-17 morning gate round (user finding: retail world tooltips "lag"; ours popped instantly): the original "edge-fired (no dwell)" reading was a misread — the notice's immediate `StartTooltipAtMouse @0x004E5DFB` sits inside `if (s_pInstance->m_dragElement != 0)` (`@0x004E5D8E`; `m_dragElement` is a real, distinct PDB field in `acclient.h`'s `UIElementManager`, separate from the `m_pTooltipElement` family), so the immediate mount is DRAG-ONLY; the ordinary hover path merely STAGES the name (`SetTooltip @0x004E5D74` + `|= 0x20`) and the display rides `CheckTooltip @0x0045B6E0`'s mouse-idle dwell (`m_lastMouseMoveTime` stamped on EVERY move `@0x0045e736` + `m_tooltipDelay` 0.25 s `@0x0045f75d`), with found-object changes under an idle mouse swapping the popup same-frame via `SetTooltip`'s own text-change teardown (`@0x004617FF` → `ResetTooltip @0x0045C360` tail-calling `CheckTooltip`) and the 10 s `m_tooltipDuration` expiry requiring a fresh mouse move before re-arming (`SwitchMouseOver(null) @0x0045b7b2`) — all now ported, including the drag-immediate branch**, reusing the SAME popup locator since an exhaustive DAT sweep found `UIElement_SmartBoxWrapper` (class `0x10000030`) has no authored `ElementDesc` anywhere installed (`TooltipLiveDatTests.SmartBoxWrapper_HasNoAuthoredElementDesc_AnywhereInstalled`) — the popup-skin choice is therefore the best-evidenced inference, not a measured value, and is called out here as such. **BATCH B (2026-08-17) CLOSED the spellcasting and character-panel rows of this list. BATCH C (2026-08-17, Map/House toolbar panel) CLOSES THE LAST REMAINING ITEM: `gmMapUI::AddMapNote @0x004A1C51`'s 53 town-hotspot tooltips are now ported via `MapPageController.BuildTownMarkers` (`src/AcDream.App/UI/Layout/MapPageController.cs`), setting `UiButton.TooltipText` (retail's RUNTIME `m_TTText`/`SetTooltip` mechanism, not the DAT-authored `P0x49` path an earlier same-day cut mistakenly used and which never rendered live during verification); literal town names from `MapLocations.cs` (a verbatim port of `s_rgLocations`), not a DAT string-table lookup, matching `AddMapNote`'s own `StringInfo::SetLiteralValue` call. CORRECTED at the same-day morning gate round (user finding 3 — the retail screenshot's green hover highlight + special-font parchment tooltip): Batch C's "the town-marker template authors no locator of its own" claim was WRONG — the template (`0x100001F0` in `0x21000026`, `MapNoteLiveDatTests`) authors its OWN `P0x47=0x10000398`/`P0x48=0x21000041` (the fourth popup skin, whose incorporated text child `0x10000396` fonts `0x40000015` where the other three skins font `0x40000002`), a zero per-element delay `P0x50=0.0`, `P0x4B` TooltipOn, and `P0x13` RolloverEnabled with PassToChildren `Normal`/`Normal_rollover` states flipping the highlight child `0x100001F1`'s per-state `P0x3B` (the green `0x06004CC9` frame, byte-decoded A=FF R=00 G=FF B=00); the hardcoded shared-skin override was removed (the built marker's authored locator wins) and the rollover highlight + per-state-`P0x3B` + button PassToChildren cascade are now ported (`UiButton.CascadeStateToChildren`, `UiDatElement.TrySetRetailState`'s 0x3B honor). Sub-mechanism (1)'s `SetTooltip`-call-site enumeration is 16 of 17 known sites PORTED — `UIElement_Text::RecalculateTruncation @0x00466F80` (the headline, highest-volume site named at the top of sub-mechanism (1)) remains the ONE open item, exactly as this row's own sub-mechanism (1) text above already scoped it out (its own "Sized as genuinely disproportionate... deferred here" note). The prior "all 15 known sites accounted for" close (F12 correction, night-round review) was wrong twice over: the count is 17, not 15, and RecalculateTruncation was never actually ported — it was always the one deliberately-deferred item, not a closed one.** Batch B audit findings: the endowment icon `@0x004C63A1`, favorite `@0x004C7206`, and submenu `@0x004C67D8` sites turned out to be ALREADY CORRECT — all three are `UiCatalogSlot`-based and the pre-existing `Label`-driven `GetTooltipText` already carried retail's exact text (`SpellCastSubMenu::AddFavorite @0x004C7060`/`UpdateFromPlayerModule @0x004C6570` both build a single-arg `Formatted` PStringBase — plain spell name, no wrapper — for the favorite-bar/submenu case; `gmSpellcastingUI::UpdateEndowmentIcon @0x004C6120` mirrors the cast button's confirmed `"%s (%hs)"` literal at `data_7b64d8` — item name, then spell name in parens — via the identical two-value narrow/wide prep sequence, one address away in the same function family). The cast button `@0x004C6FE8`/`ClearTooltip @0x004C6AAE` was the one real gap (`UiButton` had no tooltip wiring at all): now ported via `SpellcastingUiController.UpdateCastAvailability`/`ComputeEndowmentCastState`, sourced from `gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30`. Fully verified literal text: the no-selection states (`"Select a spell to cast"` @ `data_7b64ec`, `"You have no spells ready to cast"` @ `data_7b6520`) and the complete endowment-item branch (`"USE the %s"` @ `data_7b64c0`, `" on %s"` @ `data_7b6464`, `"You must select a target for the %s"` @ `data_7b6478`; `ItemUses::IsUseable_SelfTarget @0x004fcd30` is exactly `ItemUseability.AllowsSelfTarget`). NOT ported: the endowment branch's incompatible-target sub-state (`"You must select an appropriate\ntarget for the %s"` @ `data_7b6400`, gated by `ItemHolder::TargetCompatibleWithObject @0x00587520` — a ~400-line function with its own chat-message side effects, out of scope for a tooltip batch; a present target is optimistically treated as compatible, same text as the confirmed-compatible case). **CORRECTED at the night-round review (F3/F4, 2026-08-17): the plain-spell branch's wording is NOT unrecoverable — the "genuine `gmNoticeHandler` vtable SLOTS" claim above was itself the artifact. `PStringBase::sprintf`'s second argument at those three call sites is a raw pushed literal (a plain `push 0x7bXXXX; call sprintf`); Binary Ninja's pseudo-C rendering of that operand as `&gmSpellcastingUI::\`vftable'.RecvNotice_XXX` was a spurious symbol match, not the true operand — a direct capstone disassembly of the raw bytes at `0x4c6e48`/`0x4c6ea4`/`0x4c6f18`/`0x4c6f5d` resolves the actual constants: `"CAST %hs"` @0x7b63a4 (untargeted/self-cast at `0x4c6f35`, and targeted+compatible at `0x4c6e57` — both enabled, the latter appending `" on %s"` @0x7b6464 with the target's name), `"You must select an appropriate target for %hs"` @0x7b6348 (targeted+incompatible, disabled), `"You must select a target for %hs"` @0x7b63b8 (no target, disabled); `%hs` is the spell's own name in all four call sites (`CSpellBase::InqName`, the same call `0x5bbee0` throughout). Now ported: `RuntimeSpellCastState.EvaluateCastGate` (the four-state gate) + `SpellcastingUiController.ComputeSpellCastState`. Also corrected the endowment branch's "USE the %s" operand: it was NOT the bare item name (F4) — the vararg to `"USE the %s"`/`"You must select a target for the %s"`/the still-unported incompatible-target string is the SAME composed `"%s (%hs)"` string (item name, spell name) built once at `@0x004c6bb6-ef` from format literal `data_7b64d8`, byte-confirmed by all three sprintf call sites (`0x4c6c7f`/`0x4c6ca4`/`0x4c6d46`) reading the identical `[esp+0x18]` slot — now ported via `SpellcastingUiController.ComposeEndowmentName`.** **CA5-GATE CORRECTIONS (2026-08-24, owner retail-render oracle): (a) `SkillInfoRegion::GetTooltip`'s compose is `formula + " +" + description` — the row below has the operand order backwards (" +"+formula, "no separator"); the operator+ left operand is the InqSkillFormula output, and retail renders formula-first-line/description-below. (b) The "grow further if vertical scroll overflow" branch this row calls "a structural no-op" is NOT one — it is retail's SECOND sizing pass (`StartTooltip @0x0045DE90`: measure-wrap at max width → clamped root resize → `RecalculateGlyphList` RE-WRAP at the final clamped width → height growth for the extra lines), which is what makes long tooltips multi-line; both now ported in `RetailTooltipPresenter.ApplyTooltipText`/`RetailSkillFormula.BuildTooltip`.** **#430 CORRECTION (2026-08-24): the character-panel port below set the TEXT but omitted the popup LOCATOR on the runtime-built rows, so these tooltips never mounted until the CA5-adjacent fix gave rows the shared 0x10000395/0x21000041 skin (live-DAT probed; UiItemSlot precedent).** The character-panel `AttributeInfoRegion @0x004F1617` / `Attribute2ndInfoRegion @0x004F1777` / `SkillInfoRegion @0x004F222F` constructors are now fully ported through a new `UiClickablePanel.TooltipText` (the same settable-string seam as `UiButton.TooltipText`): the six hardcoded attribute descriptions (`SkillSystem::InqAttributeDescription @0x005c8e30`) and three hardcoded, pair-shared vitals descriptions (`SkillSystem::InqAttribute2ndDescription @0x005c8f70`) were byte-decoded from the retail string pool (the pseudo-C dump truncates them with "…"); skill tooltips compose `SkillInfoRegion::GetTooltip @0x004f1fe0`'s exact `"\n" + formula + description` (ported verbatim, including the confirmed lack of any separator between the formula and description text) from the ALREADY-DAT-parsed `DatReaderWriter.Types.SkillBase.Description`/`.Formula` fields (portal `0x0E000004`, the same resource `CharacterSheetProvider.SkillTable` already reads for skill names/costs) rather than hand-transcribed literals — no guessing was needed for the ~30+ skill description strings. The formula-to-text algorithm itself (`SkillSystem::InqSkillFormula @0x005c89b0`, e.g. producing `"( (Strength + Coordination) / 2 )"`) was fully recovered by byte-decoding six short literal fragments (`data_7e7930`/`7e7934`/`7e7940`/`7e7950`/`7e7954`/`797584`) the pseudo-C dump left completely unlabeled — they sit between two `gmSpellcastingUI` vtable declarations and Binary Ninja's type inference never recognized them as strings, so the raw hex had to be read directly as narrow ASCII (confirmed against the function's own directly-visible `" / %u"` and `"(%u x %s)"` literals, which needed no such recovery). Retail's runtime sites also SET the `P0x4B` on-bit themselves (`__bitfield164 |= 0x20`, eight sites) — the port models that as "runtime text present implies tooltip-on", so only the authored-text path consults the authored bit. **(2) The per-element wrap-width override:** `UIElement_Text::InqSizewMargins @0x00469660`'s `UITS_MAX_WIDTH` branch checks `GetAttribute_Int(this, 0x3D, ...)` before falling back to `RenderDevice::GetDisplayWidth()`; `RetailTooltipPresenter.ApplyTooltipText` always wraps at `UiRoot.EffectiveCanvasSize.X` (the confirmed fallback) and never checks for a `P0x3D` override — the live-DAT sweep found zero tooltip-bearing elements author one. | `src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs` (`ResolveTooltipText`'s runtime-then-authored order; `ApplyTooltipText`'s wrap-width literal; `UpdateWorldHoverTooltip`/`TryBuildAndMountPopup` — the world-hover half added 2026-08-16); `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo.TooltipText`'s own doc comment carries the same F3 correction); `src/AcDream.App/UI/UiItemSlot.cs` (`TooltipTextResolve`, `GetTooltipText`, the hardcoded popup-locator constants); `src/AcDream.Core/Items/ClientObject.cs` (`GetTooltipDisplayName`); `src/AcDream.App/UI/CursorFeedbackController.cs` (the related #411 found-cursor fix, same round — see that row); Batch B (2026-08-17) additions: `src/AcDream.App/UI/UiPanel.cs` (`UiClickablePanel.TooltipText`/`GetTooltipText`); `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`UpdateCastAvailability`, `ComputeEndowmentCastState`); night-round review (F3/F4, 2026-08-17) additions: `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`ComputeSpellCastState`, `ComposeEndowmentName`); `src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs` (`EvaluateCastGate`, `SpellCastGate`); `src/AcDream.App/Net/RetailSkillFormula.cs` (`AttributeName`, `FormatFormula`, `BuildTooltip`); `src/AcDream.App/UI/Layout/CharacterSheet.cs` (`CharacterSkill.TooltipText`); `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`BuildLiveCharacterSkills`'s tooltip compose); `src/AcDream.App/UI/Layout/CharacterStatController.cs` (`AttributeDescriptions`, `Attribute2ndDescriptions`, `BuildAttributeRows`/`BuildSkillRows` row wiring) | The 243 elements WITH literal text — the "core" case #409 ships — cover every hover-text scenario the investigation's own landmark checks exercised (main-game-UI Appearance-page rotate/color/spin hints, etc.). F9 correction: "243 with literal text" is not automatically "243 showable" — `RetailTooltipPresenter.OnTooltipShow`'s real gate is the FULL conjunction of `TooltipEnabled` (P0x4B) AND non-null text (P0x49) AND both popup-locator ids (P0x47/P0x48) — so this was measured, not assumed: `TooltipLiveDatTests.ClientWideSweep_FindsKnownLandmarksAndAFloorCount`'s `Showable` column finds the intersection is exactly 243, i.e. every element authoring literal text also authors the other three properties together (they are evidently authored as one group in practice). The P0x3D sweep found zero authoring elements, so the unconditional display-width fallback is not an approximation for any element that exists today. | If a future DAT revision adds a `P0xD0`-truncated text element, a game-code `SetTooltip` caller, or authors a `P0x3D` override, it silently shows no tooltip / wraps at the wrong width instead of erroring — indistinguishable from "the element authors no tooltip at all" without re-running the sweep AND separately auditing which of the 187 no-literal-text elements would actually truncate at their authored width. | `UIElement::InqProperty @0x004638D0` (base authored-bag read, NOT a dynamic override); `UIElement::StartTooltipAtMouse @0x00460D70` (`m_TTText`-vs-`InqProperty` preference order); `UIElement_Text::RecalculateTruncation @0x00466F80` (`P0xD0` gate, `SetTooltip`/`ClearTooltip` sites); `UIElement_Text::InqSizewMargins @0x00469660` (`UITS_MAX_WIDTH` branch, `GetAttribute_Int(this, 0x3D, ...)`); Batch B (2026-08-17): `gmSpellcastingUI::UpdateCastButtonTooltip @0x004C6A30` (cast-button state machine); `gmSpellcastingUI::UpdateEndowmentIcon @0x004C6120` (endowment-icon `"%s (%hs)"` confirmation); `SpellCastSubMenu::AddFavorite @0x004C7060` / `UpdateFromPlayerModule @0x004C6570` (favorite/submenu plain-name confirmation); `ItemUses::IsUseable_SelfTarget @0x004fcd30`; `AttributeInfoRegion::AttributeInfoRegion @0x004f1530` / `Attribute2ndInfoRegion::Attribute2ndInfoRegion @0x004f1680` / `SkillInfoRegion::SkillInfoRegion @0x004f2140` / `SkillInfoRegion::GetTooltip @0x004f1fe0`; `SkillSystem::InqAttributeName @0x005c8d90` / `InqAttributeDescription @0x005c8e30` / `InqAttribute2ndName @0x005c8ed0` / `InqAttribute2ndDescription @0x005c8f70` / `InqSkillFormula @0x005c89b0` | | TS-84 | Chargen 3D preview (Campaign CC slice CC6a foundation): `ChargenClothingTable`'s composer skips retail's ~8-branch Setup-id substitution chain (`ClothingTable::BuildObjDesc @ 0x005A7900`'s Umbraen/Penumbraen/Undead/Anakshay fallback) when a garment's `ClothingBaseEffects` has no entry for the resolved body Setup. MEASURED (not assumed) against the installed EoR dat across all 26 heritage/gender combinations via `ChargenAppearanceCatalogInstalledDatTests`, with the measurement now PINNED by a real assertion rather than diagnostic-only output (review fix round F7): the 9 standard heritages whose UI actually shows clothing controls resolve every default gear choice with zero coverage gaps. Undead is a real gap — its default gear choices (both genders) have NO base-effect entry on **ALL FOUR clothing slots — headgear, trousers, shirt, AND footwear** (not the three-slot "headgear/trousers/footwear" this row originally understated, with a self-contradicting "4 of 4 non-shirt slots" aside — corrected at the review fix round F2) — for Undead's own live body Setup (male 0x02001A9C / female 0x02001AA0), because that Setup is one of the skeleton/zombie variants the un-ported chain exists to redirect. The four measured missing clothing-table ids are identical on both genders and in a fixed order: `0x10000009, 0x100000F9, 0x10000001, 0x10000007` (Headgear, Trousers, Shirt, Footwear — the factory's own composition order). Gear Knight and both Olthoi variants also show gaps under a synthetic "select every offered option" sweep, but retail hides the clothing controls entirely for those three heritages (`gmCGAppearancePage::Update @ 0x0047E8F0`'s `m_pClothesButton->SetVisible(0)` branches for `mHeritageGroup == 6` and `== 0xc \|\| == 0xd`), so a real chargen selection never reaches them — not a live gap. | `src/AcDream.Core/CharGen/ChargenClothingTable.cs`; `src/AcDream.Core/CharGen/ChargenAppearanceFactory.cs` (`ComposeClothingSlot`) | CC6a is explicitly the rendering-foundation slice (index→ObjDesc factory + static-pose offscreen renderer, no page mount yet); porting the ~8-branch substitution chain is bounded follow-up work once CC6b wires real clothing-slot UI, not a blocker for the foundation deliverable — and the installed-DAT test proves the gap is narrow (one heritage, all four of ITS slots) rather than pervasive. | Undead's default clothing preview renders the bare body mesh for ALL FOUR slots — headgear, trousers, shirt, AND footwear (no clothing part/texture override applied on any of them, though the dye subpalette contribution — gated on a DIFFERENT lookup — is unaffected) — until the chain, or an equivalent per-heritage default-clothing-Setup map, is ported. | `ClothingTable::BuildObjDesc @ 0x005A7900` (Umbraen/Penumbraen/Undead/Anakshay Setup-substitution branches); `gmCGAppearancePage::Update @ 0x0047E8F0` (clothes-button visibility gate); `tests/AcDream.Content.Tests/CharGen/ChargenAppearanceCatalogInstalledDatTests.cs` | | TS-73 | **NARROWED 2026-08-11 at Campaign OP slice OP4.** `RuntimeCharacterOptionsState.TrySetOption`'s port of `CPlayerModule::OnChanged @0x0059A8E0`'s local side-effect switch (step 2) still covers only the two `PlayerModule`-state-mutating cases (`case 2`/`case 0x12` fellowship mutual exclusion) — that part is unchanged. Of the four presentation-binding cases, TWO are now closed: `0x07 ViewCombatTarget` (re-pointed `ICombatGameplaySettingsSource` reads `RuntimeCharacterOptionsState` live — `CharacterOptionCombatSettingsSource`, `src/AcDream.App/Combat/LiveCombatAttackOperations.cs`) and `0x30 DisableDistanceFog` (`WeatherSystem.DisableDistanceFogSource`, a poll bound once in `GameWindow.cs`, forces `FogMode.Off` in `WeatherSystem.Snapshot`) — NEITHER lives inside `TrySetOption`'s own switch; both are separate App-layer poll bindings, so the literal claim in this row's title ("this Runtime-only seam can reach") stays true, but the user-observable symptom is fixed for these two ids. The remaining two, `0x04 DisableMostWeatherEffects` and `0x05 PersistentAtDay`, stay open — see TS-6 (weather-particle subsystem not yet located) and TS-75 (day/night force) respectively; this row no longer duplicates either. | `src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs` (`RuntimeCharacterOptionsState.TrySetOption`) | The remaining two options are correctly scoped to their OWN pre-existing/new rows (TS-6, TS-75) rather than re-litigated here. | Toggling `DisableMostWeatherEffects`/`PersistentAtDay` writes the bit and dirties/auto-saves it correctly, but produces NONE of retail's immediate local presentation change (weather doesn't stop, day/night doesn't force) — see TS-6/TS-75 for why. `ViewCombatTarget`/`DisableDistanceFog` are retired from this row's risk: both now behave correctly. | `CPlayerModule::OnChanged @0x0059A8E0`; `docs/research/2026-08-10-character-options-map.md` §1.5 | | TS-75 | "Always Daylight Outdoors" (`PlayerOption PersistentAtDay`, `CPlayerModule::OnChanged` case `0x05` → `LScape::SetDay(value)`) has no acdream consumer. The campaign plan's own Group-B binding table cites `RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` as the target seam — **that citation is a mechanism mismatch, corrected here**: `ForcedDayGroupIndex` selects which WEATHER-VARIETY day-group (`RuntimeWorldDayGroupDefinition`, e.g. a Clear/Overcast/Rain/Snow/Storm pick) is always chosen — the SAME deterministic-per-day-RNG mechanism `WeatherSystem`'s own roll uses (see TS-6) — NOT retail's time-of-day day/night force. No acdream mechanism currently overrides the sky cycle's TIME to stay in daytime lighting; wiring this option correctly needs that mechanism built first, not just a poll into the wrong field. | `src/AcDream.Runtime/World/RuntimeWorldEnvironmentState.cs` (`RuntimeWorldEnvironmentDefinition.ForcedDayGroupIndex` — NOT the right target); no current consumer exists | Filed rather than silently wired to the wrong field — a poll into `ForcedDayGroupIndex` would have SILENTLY changed the character's weather-variety odds instead of forcing daytime, an incorrect fix masquerading as a correct one (CLAUDE.md's "no workarounds" rule). | Toggling the option writes the bit and dirties/auto-saves it correctly, but night still falls normally — no observable daylight-forcing behavior. | `CPlayerModule::OnChanged @0x0059A8E0` case 5; `LScape::SetDay` (not yet located in the decomp) | diff --git a/src/AcDream.App/Net/RetailSkillFormula.cs b/src/AcDream.App/Net/RetailSkillFormula.cs index 70dc47ef..18aab4d5 100644 --- a/src/AcDream.App/Net/RetailSkillFormula.cs +++ b/src/AcDream.App/Net/RetailSkillFormula.cs @@ -186,12 +186,19 @@ internal static class RetailSkillFormula /// /// Retail SkillInfoRegion::GetTooltip @ 0x004f1fe0, called once /// from SkillInfoRegion::SkillInfoRegion @ 0x004f2140's - /// UIElement::SetTooltip at 0x004f222f. Composition is exactly - /// "\n" + formula + description — retail concatenates the - /// description directly onto the formula line with NO separator between - /// them (ported verbatim, not "fixed": append_n_chars runs - /// immediately after the formula assignment with no intervening - /// literal). SkillSystem::InqSkillDescription @ 0x005c8770 reads + /// UIElement::SetTooltip at 0x004f222f. Composition is + /// formula + "\n" + description — CORRECTED 2026-08-24 at the + /// CA5 gate: the original Batch-B reading ("\n" + formula, no + /// separator) had the operator+ operand order backwards + /// (PVar3 = operator+(&local_c, ...)local_c, the + /// InqSkillFormula output, is the LEFT operand; the "\n" literal is the + /// right), and the owner's retail-client render confirms: formula on + /// its own first line, description below (wrapping to further lines + /// when the DAT text is long). A formula-less skill (Salvaging) shows + /// the bare description with no leading break, matching retail's + /// failed-InqSkillFormula branch, which appends the description + /// to the still-empty output. + /// SkillSystem::InqSkillDescription @ 0x005c8770 reads /// SkillBase._description — the same DAT field /// already /// exposes, so no hand-transcription was needed for the ~30+ skill @@ -204,7 +211,7 @@ internal static class RetailSkillFormula string? formula = FormatFormula(skillBase.Formula); string description = skillBase.Description.Value ?? string.Empty; - string tooltip = (formula is null ? string.Empty : "\n" + formula) + description; + string tooltip = (formula is null ? string.Empty : formula + "\n") + description; return tooltip.Length == 0 ? null : tooltip; } } diff --git a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs index d217dcaa..cef1ced8 100644 --- a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs +++ b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs @@ -626,15 +626,27 @@ public sealed class RetailTooltipPresenter : IDisposable ? bitmapFont.MeasureWidth : static s => s.Length * 8f; - float wrapWidth = MathF.Max(1f, _host.EffectiveCanvasSize.X); - var wrapped = UiText.WrapWords(tooltipText, measure, wrapWidth); - text.LinesProvider = () => wrapped - .Select(line => new UiText.Line(line, text.DefaultColor)) - .ToArray(); - - float measuredWidth = wrapped.Count == 0 ? 0f : wrapped.Max(measure); + // CA5-gate correction (2026-08-24, owner retail-render oracle): + // retail sizes a tooltip in TWO passes, and the second is what makes + // long tooltips multi-line. StartTooltip @0x0045DE90: + // 1. MEASURE — InqSizewMargins(..., UITS_MAX_WIDTH): wrap at the + // authored P0x3D max width, else the display width, producing + // the measured extent. + // 2. Resize the ROOT by the measured-vs-authored delta through + // ResizeTo, where the popup skin's authored max/min CLAMP. + // 3. RecalculateGlyphList — the text RE-WRAPS at its FINAL + // (possibly clamped) width. + // 4. A second ResizeTo grows the root's HEIGHT (width unchanged) + // when the re-wrapped glyph extent needs more than the text + // child's current height. + // The pre-correction port did only pass 1, so a description longer + // than the clamped popup stayed one clipped line. float lineHeight = text.DatFont?.LineHeight ?? text.Font?.LineHeight ?? 14f; - float measuredHeight = wrapped.Count * lineHeight; + + float measureWrapWidth = MathF.Max(1f, _host.EffectiveCanvasSize.X); + var measured = UiText.WrapWords(tooltipText, measure, measureWrapWidth); + float measuredWidth = measured.Count == 0 ? 0f : measured.Max(measure); + float measuredHeight = measured.Count * lineHeight; float requestedWidth = root.Width + (measuredWidth - authoredTextWidth); float requestedHeight = root.Height + (measuredHeight - authoredTextHeight); @@ -651,10 +663,40 @@ public sealed class RetailTooltipPresenter : IDisposable if (root.AuthoredResizeMinWidth is { } minWidth && requestedWidth < minWidth) requestedWidth = minWidth; + float authoredRootWidth = root.Width; + float authoredRootHeight = root.Height; root.Width = requestedWidth; root.Height = requestedHeight; - text.Width = measuredWidth; - text.Height = measuredHeight; + + // The text child follows the root's ACTUAL growth (retail's + // anchored resize) — the clamp is what makes these differ from the + // measured extents. + float textFinalWidth = MathF.Max( + 1f, authoredTextWidth + (requestedWidth - authoredRootWidth)); + float textFinalHeight = + authoredTextHeight + (requestedHeight - authoredRootHeight); + + // Pass 3: re-wrap at the final clamped width (RecalculateGlyphList). + var wrapped = UiText.WrapWords(tooltipText, measure, textFinalWidth); + text.LinesProvider = () => wrapped + .Select(line => new UiText.Line(line, text.DefaultColor)) + .ToArray(); + text.Width = wrapped.Count == 0 ? 0f : MathF.Min(textFinalWidth, wrapped.Max(measure)); + float rewrappedHeight = wrapped.Count * lineHeight; + + // Pass 4: grow the root's height by the re-wrap's overflow beyond + // the text child's post-resize height (width unchanged), through + // the same ResizeTo clamps. + if (rewrappedHeight > textFinalHeight) + { + float grownHeight = root.Height + (rewrappedHeight - textFinalHeight); + if (root.AuthoredResizeMaxHeight is { } maxH2 && grownHeight > maxH2) + grownHeight = maxH2; + if (root.AuthoredResizeMinHeight is { } minH2 && grownHeight < minH2) + grownHeight = minH2; + root.Height = grownHeight; + } + text.Height = rewrappedHeight; } /// Retail UIElementManager::StartTooltip @0x00459700: the diff --git a/tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs b/tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs index 66b10dc1..1e1e3547 100644 --- a/tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs +++ b/tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs @@ -143,6 +143,48 @@ public sealed class RetailSkillFormulaTests Assert.Equal(0u, resolver.Resolve(0x999u, new Dictionary())); } + [Fact] + public void BuildTooltip_FormulaFirstThenNewlineThenDescription() + { + // CA5 gate correction (2026-08-24): retail renders the formula on + // its own FIRST line with the description below — GetTooltip + // @0x004f1fe0's operator+ has the InqSkillFormula output as the + // LEFT operand (formula + a newline), then appends the description; + // confirmed against the owner's retail-client render. The original + // reading (newline-first + formula, glued description) produced a leading + // blank line and a single glued line. + SkillFormula formula = Formula(w: 0, x: 1, y: 1, z: 2); + formula.Attribute1 = DatReaderWriter.Enums.AttributeId.Strength; + formula.Attribute2 = DatReaderWriter.Enums.AttributeId.Coordination; + var skillBase = new SkillBase + { + Formula = formula, + Description = { Value = "Description text." }, + }; + + string? tooltip = RetailSkillFormula.BuildTooltip(skillBase); + + Assert.NotNull(tooltip); + Assert.False(tooltip!.StartsWith('\n')); + int split = tooltip.IndexOf('\n'); + Assert.True(split > 0); + Assert.Equal("Description text.", tooltip[(split + 1)..]); + } + + [Fact] + public void BuildTooltip_FormulaLessSkillShowsBareDescription() + { + // Retail's failed-InqSkillFormula branch (z==0) appends the + // description to a still-empty string — no leading break. + var skillBase = new SkillBase + { + Formula = Formula(w: 7, x: 1, y: 1, z: 0), + Description = { Value = "Salvage things." }, + }; + + Assert.Equal("Salvage things.", RetailSkillFormula.BuildTooltip(skillBase)); + } + private static SkillFormula Formula(int w, int x, int y, uint z) => new() { AdditiveBonus = w, diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs index 12ac3c0d..4cb985a3 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs @@ -464,6 +464,69 @@ public sealed class RetailTooltipPresenterTests return target; } + [Fact] + public void LongTooltip_RewrapsAtTheClampedPopupWidth_AndGrowsHeightForTheExtraLines() + { + // CA5-gate correction: retail sizes tooltips in TWO passes + // (StartTooltip @0x0045DE90) — measure-wrap at the max width, resize + // the root through the authored clamps, then RE-WRAP at the final + // clamped width and grow the root's HEIGHT for the extra lines. + // Pre-correction, a description longer than the clamped popup stayed + // one clipped line (the owner's retail client shows three). + var (root, _, _) = CreateHarness(); + string longText = string.Join( + " ", Enumerable.Repeat("description", 40)); // far wider than 200px + var target = new HoverTarget + { + Left = 100, Top = 100, Width = 40, Height = 20, + AuthoredTooltipEnabled = true, + AuthoredTooltipText = longText, + AuthoredTooltipRootElementId = PopupRootId, + AuthoredTooltipLayoutDid = PopupLayoutDid, + }; + root.AddChild(target); + int childrenBefore = root.Children.Count; + + // Clamp the popup to 200px wide via a max-width-authored skin. + HoverAndDwellWithClampedPopup(root, maxWidth: 200); + + UiElement popup = root.Children[^1]; + Assert.True(root.Children.Count > childrenBefore, "popup did not mount"); + Assert.True(popup.Width <= 200f, $"popup width {popup.Width} escaped the clamp"); + var text = FindText(popup); + Assert.NotNull(text); + var lines = text!.LinesProvider(); + Assert.True(lines.Count >= 3, + $"expected the clamped width to force >=3 lines, got {lines.Count}"); + // Every re-wrapped line must fit the clamped popup. + Assert.All(lines, l => Assert.True(l.Text.Length * 8f <= 200f + 8f)); + // The root grew to hold the extra lines. + float lineHeight = 14f; + Assert.True(popup.Height >= lines.Count * lineHeight, + $"popup height {popup.Height} does not fit {lines.Count} lines"); + } + + private static void HoverAndDwellWithClampedPopup(UiRoot root, int maxWidth) + { + // A presenter whose popup skin authors ResizeMaxWidth, mirroring the + // parchment skins' bounded frames. + var presenter = new RetailTooltipPresenter(root, (_, _) => + { + ImportedLayout layout = BuildPopup(); + layout.Root.AuthoredResizeMaxWidth = maxWidth; + return layout; + }); + HoverAndDwell(root); + } + + private static UiText? FindText(UiElement element) + { + if (element is UiText text) return text; + foreach (UiElement child in element.Children) + if (FindText(child) is { } found) return found; + return null; + } + private static void HoverAndDwell(UiRoot root) { root.OnMouseMove(110, 110); From d087b50aa3495db50a40f415365d2ec9321327a2 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 18:29:03 +0200 Subject: [PATCH 24/89] fix(ui): tooltip wrap bound comes from the popup text child's authored P0x3D, and tooltip text left-aligns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner screenshots vs retail at the CA5 re-check caught both: Wrap width: the popup skins' shared TEXT CHILD (0x10000396) authors P0x3D=256 on all four skins — live-DAT probed, now pinned by an installed-DAT test. Retail's InqSizewMargins UITS_MAX_WIDTH reads the text element's 0x3D BEFORE the display-width fallback, so retail wraps tooltip text at 256px; our measure pass used the display width because TS-85's 'zero elements author P0x3D' sweep had only covered hover TARGETS, never the popup skins. ApplyTooltipText now measures and re-wraps at the text child's authored bound, falling back to the display width only when none is authored. Alignment: tooltip text rendered centered where retail hugs the left edge. The skin authors no justification; retail's unauthored default is Left, our importer's ElementInfo default is Center — the same wrong-default class as #410's VJustify finding, now recorded there as the horizontal sibling. Point-fixed in the presenter exactly as the chat transcript already does; the client-wide default flip stays #410's scope. The two-pass sizing test now models the real skin (max width on the text child) and asserts left alignment. Full hermetic suite 15,332 passed / 0 failed; the new live-DAT pin passes against the installed DATs. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 9 ++++ .../UI/Layout/RetailTooltipPresenter.cs | 23 +++++++++- .../UI/Layout/RetailTooltipPresenterTests.cs | 17 ++++--- .../UI/Layout/TooltipSkinLiveDatTests.cs | 45 +++++++++++++++++++ 4 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/Layout/TooltipSkinLiveDatTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 6b9fd3fa..5b2a7dc5 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -1986,6 +1986,15 @@ porting. ## #410 — Client-wide VJustify (vertical text justification) enum mapping + unauthored default are wrong (retail default is Top, not Center) +**HJustify sibling evidence (2026-08-24, CA5 tooltip re-check):** the SAME +wrong-default class exists horizontally — `ElementInfo.HJustify` defaults +to Center while retail's unauthored default is Left. Observed live: the +tooltip popup text (whose authored skin sets no justification) rendered +centered where retail left-aligns; point-fixed in +`RetailTooltipPresenter.ApplyTooltipText` (the chat transcript carries the +same point-fix). When this issue's client-wide default sweep runs, fix H +and V together. + **Status:** OPEN **Severity:** MEDIUM (silently mispositions every DAT-imported `UiText` that relies on the unauthored default, or that authors a raw vertical- diff --git a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs index cef1ced8..4982e10c 100644 --- a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs +++ b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs @@ -643,7 +643,26 @@ public sealed class RetailTooltipPresenter : IDisposable // than the clamped popup stayed one clipped line. float lineHeight = text.DatFont?.LineHeight ?? text.Font?.LineHeight ?? 14f; - float measureWrapWidth = MathF.Max(1f, _host.EffectiveCanvasSize.X); + // CA5 re-check corrections (2026-08-24, owner screenshots vs retail): + // (a) The popup skins' TEXT CHILD (0x10000396) authors P0x3D=256 — + // live-DAT probed on all four skins. InqSizewMargins' + // UITS_MAX_WIDTH branch reads GetAttribute_Int(0x3D) on the TEXT + // element BEFORE the display-width fallback, so retail wraps + // tooltip text at 256px, not the screen width. (TS-85's "zero + // elements author P0x3D" sweep only covered hover TARGETS, never + // the popup skins' text children.) + // (b) Tooltip text is LEFT-aligned: the text child authors no + // justification and retail's unauthored default is Left, while + // our importer's ElementInfo default is Center — the same + // wrong-default class as #410's VJustify finding. Point-fixed + // here (the chat transcript does the same); the client-wide + // default remains #410's scope. + text.Centered = false; + text.RightAligned = false; + + float measureWrapWidth = text.AuthoredResizeMaxWidth is { } authoredMaxTextWidth + ? MathF.Max(1f, authoredMaxTextWidth) + : MathF.Max(1f, _host.EffectiveCanvasSize.X); var measured = UiText.WrapWords(tooltipText, measure, measureWrapWidth); float measuredWidth = measured.Count == 0 ? 0f : measured.Max(measure); float measuredHeight = measured.Count * lineHeight; @@ -673,6 +692,8 @@ public sealed class RetailTooltipPresenter : IDisposable // measured extents. float textFinalWidth = MathF.Max( 1f, authoredTextWidth + (requestedWidth - authoredRootWidth)); + if (text.AuthoredResizeMaxWidth is { } textMaxWidth) + textFinalWidth = MathF.Min(textFinalWidth, textMaxWidth); float textFinalHeight = authoredTextHeight + (requestedHeight - authoredRootHeight); diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs index 4cb985a3..30050e50 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs @@ -492,14 +492,18 @@ public sealed class RetailTooltipPresenterTests UiElement popup = root.Children[^1]; Assert.True(root.Children.Count > childrenBefore, "popup did not mount"); - Assert.True(popup.Width <= 200f, $"popup width {popup.Width} escaped the clamp"); + // Root = authored 30 frame grown to hold the <=200 text plus padding. + Assert.True(popup.Width <= 200f + 30f, $"popup width {popup.Width} escaped the clamp"); var text = FindText(popup); Assert.NotNull(text); + // Retail's unauthored justification default is LEFT (#410 class). + Assert.False(text!.Centered); + Assert.False(text.RightAligned); var lines = text!.LinesProvider(); Assert.True(lines.Count >= 3, $"expected the clamped width to force >=3 lines, got {lines.Count}"); // Every re-wrapped line must fit the clamped popup. - Assert.All(lines, l => Assert.True(l.Text.Length * 8f <= 200f + 8f)); + Assert.All(lines, l => Assert.True(l.Text.Length * 8f <= 200f + 8f, $"line escaped wrap: {l.Text}")); // The root grew to hold the extra lines. float lineHeight = 14f; Assert.True(popup.Height >= lines.Count * lineHeight, @@ -508,12 +512,15 @@ public sealed class RetailTooltipPresenterTests private static void HoverAndDwellWithClampedPopup(UiRoot root, int maxWidth) { - // A presenter whose popup skin authors ResizeMaxWidth, mirroring the - // parchment skins' bounded frames. + // The REAL skins bound the wrap on the TEXT CHILD: 0x10000396 + // authors P0x3D=256 on all four popup skins (live-DAT probed + // 2026-08-24) — retail's InqSizewMargins UITS_MAX_WIDTH reads the + // text element's 0x3D before the display-width fallback. var presenter = new RetailTooltipPresenter(root, (_, _) => { ImportedLayout layout = BuildPopup(); - layout.Root.AuthoredResizeMaxWidth = maxWidth; + if (layout.FindElement(TextChildId) is { } textChild) + textChild.AuthoredResizeMaxWidth = maxWidth; return layout; }); HoverAndDwell(root); diff --git a/tests/AcDream.App.Tests/UI/Layout/TooltipSkinLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/TooltipSkinLiveDatTests.cs new file mode 100644 index 00000000..764ef742 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/TooltipSkinLiveDatTests.cs @@ -0,0 +1,45 @@ +using AcDream.App.UI.Layout; +using DatReaderWriter; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// CA5 re-check pin (2026-08-24): the popup skins' shared TEXT CHILD +/// (0x10000396) authors P0x3D=256 — the wrap width retail's +/// InqSizewMargins UITS_MAX_WIDTH reads BEFORE its display-width +/// fallback. TS-85's earlier "zero elements author P0x3D" sweep covered +/// hover TARGETS only, never the popup skins; this pin closes that gap so +/// a DAT revision (or importer regression) that loses the bound fails +/// loudly instead of silently un-wrapping every tooltip. +/// +[Trait("Lane", "InstalledDat")] +public sealed class TooltipSkinLiveDatTests +{ + private static string DatDirectory => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + + [InstalledDatFact] + public void EveryPopupSkinTextChildAuthorsTheRetailWrapBound() + { + using var dats = new DatCollection( + DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + ElementInfo? tree = LayoutImporter.ImportInfos(dats, 0x21000041u); + Assert.NotNull(tree); + + int textChildren = 0; + void Walk(ElementInfo e) + { + if (e.Id == 0x10000396u && e.Type == 12) + { + textChildren++; + Assert.Equal(256, e.MaxWidth); + } + foreach (var c in e.Children) Walk(c); + } + Walk(tree!); + Assert.True(textChildren >= 4, + $"expected the text child under all four popup skins, found {textChildren}"); + } +} From 35abbe1d0d0354fe95cf22b901aba929a13da381 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 18:44:28 +0200 Subject: [PATCH 25/89] fix #430: tooltip wrap and draw honor the text child's authored margins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third owner-screenshot round: acdream fit one more word per line than retail and drew glyphs flush against the popup's right border. Retail's InqSizewMargins @0x00469660 wraps the glyph list at (bound - m_margL - m_margR) and adds the margins back into the measured width; the popup skins' shared text child 0x10000396 authors margins L=2/R=2 (U=2/D=2 on three of the four skins — live-DAT probed). The presenter now subtracts the horizontal margins from both wrap passes, re-adds them into the measured width used for root sizing, and counts the vertical margins in the measured/re-wrapped heights; the widget's own draw already insets by all four margins (UiText ContentOffsetX + the top/bottom inset), so the right-side spacing returns for free. TooltipSkinLiveDatTests pins the authored margins per skin alongside the P0x3D=256 wrap bound; a new presenter test proves margins shrink the wrap bound and survive onto the widget. Co-Authored-By: Claude Fable 5 --- .../UI/Layout/RetailTooltipPresenter.cs | 31 +++++++--- .../UI/Layout/RetailTooltipPresenterTests.cs | 56 ++++++++++++++++++- .../UI/Layout/TooltipSkinLiveDatTests.cs | 16 +++++- 3 files changed, 92 insertions(+), 11 deletions(-) diff --git a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs index 4982e10c..8f54e684 100644 --- a/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs +++ b/src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs @@ -660,12 +660,25 @@ public sealed class RetailTooltipPresenter : IDisposable text.Centered = false; text.RightAligned = false; - float measureWrapWidth = text.AuthoredResizeMaxWidth is { } authoredMaxTextWidth + // (c) The text child's authored margins (P0x23-0x26 — L2/R2/U2/D2 on + // the popup skins) participate exactly as InqSizewMargins does: + // GlyphList::Recalculate wraps at (width − margL − margR) and + // the measured result adds the margins back + // (@0x00469762/@0x004697..: `Recalculate(..., w − margL − margR)` + // then `*out += margR + margL`). Without this, line one fit one + // more word than retail (256 vs retail's 252 wrap) and the text + // drew flush against the parchment's right border. + float marginsX = text.MarginLeft + text.MarginRight; + float marginsY = text.MarginTop + text.MarginBottom; + + float wrapBound = text.AuthoredResizeMaxWidth is { } authoredMaxTextWidth ? MathF.Max(1f, authoredMaxTextWidth) : MathF.Max(1f, _host.EffectiveCanvasSize.X); + float measureWrapWidth = MathF.Max(1f, wrapBound - marginsX); var measured = UiText.WrapWords(tooltipText, measure, measureWrapWidth); - float measuredWidth = measured.Count == 0 ? 0f : measured.Max(measure); - float measuredHeight = measured.Count * lineHeight; + float measuredWidth = + (measured.Count == 0 ? 0f : measured.Max(measure)) + marginsX; + float measuredHeight = measured.Count * lineHeight + marginsY; float requestedWidth = root.Width + (measuredWidth - authoredTextWidth); float requestedHeight = root.Height + (measuredHeight - authoredTextHeight); @@ -697,13 +710,17 @@ public sealed class RetailTooltipPresenter : IDisposable float textFinalHeight = authoredTextHeight + (requestedHeight - authoredRootHeight); - // Pass 3: re-wrap at the final clamped width (RecalculateGlyphList). - var wrapped = UiText.WrapWords(tooltipText, measure, textFinalWidth); + // Pass 3: re-wrap at the final clamped width, inside the margins + // (RecalculateGlyphList wraps glyphs at width − margL − margR). + var wrapped = UiText.WrapWords( + tooltipText, measure, MathF.Max(1f, textFinalWidth - marginsX)); text.LinesProvider = () => wrapped .Select(line => new UiText.Line(line, text.DefaultColor)) .ToArray(); - text.Width = wrapped.Count == 0 ? 0f : MathF.Min(textFinalWidth, wrapped.Max(measure)); - float rewrappedHeight = wrapped.Count * lineHeight; + text.Width = wrapped.Count == 0 + ? 0f + : MathF.Min(textFinalWidth, wrapped.Max(measure) + marginsX); + float rewrappedHeight = wrapped.Count * lineHeight + marginsY; // Pass 4: grow the root's height by the re-wrap's overflow beyond // the text child's post-resize height (width unchanged), through diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs index 30050e50..3e6d008b 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailTooltipPresenterTests.cs @@ -510,7 +510,52 @@ public sealed class RetailTooltipPresenterTests $"popup height {popup.Height} does not fit {lines.Count} lines"); } - private static void HoverAndDwellWithClampedPopup(UiRoot root, int maxWidth) + [Fact] + public void AuthoredMargins_ShrinkTheWrapBound_AndInsetTheMeasuredWidth() + { + // The real skins' text child (0x10000396) authors margins L=2/R=2 + // (U=2/D=2 on three of four skins). Retail's InqSizewMargins + // @0x00469660 wraps at (bound − margL − margR) and adds the margins + // back into the measured width; without this, line one fit one more + // word than retail and the glyphs drew flush against the popup's + // right border (CA5 owner screenshot pair, 2026-08-24). + var (root, _, _) = CreateHarness(); + string longText = string.Join( + " ", Enumerable.Repeat("description", 40)); + var target = new HoverTarget + { + Left = 100, Top = 100, Width = 40, Height = 20, + AuthoredTooltipEnabled = true, + AuthoredTooltipText = longText, + AuthoredTooltipRootElementId = PopupRootId, + AuthoredTooltipLayoutDid = PopupLayoutDid, + }; + root.AddChild(target); + + // Wide horizontal margins make the difference unmissable at the + // 8px-per-char test measure: the wrap bound is 200−80=120. + HoverAndDwellWithClampedPopup(root, maxWidth: 200, marginX: 40, marginY: 8); + + var text = FindText(root.Children[^1]); + Assert.NotNull(text); + var lines = text!.LinesProvider(); + Assert.True(lines.Count >= 3, $"expected >=3 wrapped lines, got {lines.Count}"); + // Glyphs wrap INSIDE the margins... + Assert.All(lines, l => Assert.True( + l.Text.Length * 8f <= 200f - 80f + 8f, + $"line ignored the margins: {l.Text}")); + // ...and the widget keeps the margins so the draw insets by them + // (the right-side spacing the owner's retail screenshot shows). + Assert.Equal(40f, text.MarginLeft); + Assert.Equal(40f, text.MarginRight); + // Height accounts for the vertical margins on top of the lines. + float lineHeight = 14f; + Assert.True(root.Children[^1].Height >= lines.Count * lineHeight + 16f, + $"popup height {root.Children[^1].Height} lost the vertical margins"); + } + + private static void HoverAndDwellWithClampedPopup( + UiRoot root, int maxWidth, int marginX = 0, int marginY = 0) { // The REAL skins bound the wrap on the TEXT CHILD: 0x10000396 // authors P0x3D=256 on all four popup skins (live-DAT probed @@ -520,7 +565,16 @@ public sealed class RetailTooltipPresenterTests { ImportedLayout layout = BuildPopup(); if (layout.FindElement(TextChildId) is { } textChild) + { textChild.AuthoredResizeMaxWidth = maxWidth; + if (textChild is UiText t) + { + t.MarginLeft = marginX; + t.MarginRight = marginX; + t.MarginTop = marginY; + t.MarginBottom = marginY; + } + } return layout; }); HoverAndDwell(root); diff --git a/tests/AcDream.App.Tests/UI/Layout/TooltipSkinLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/TooltipSkinLiveDatTests.cs index 764ef742..ae067638 100644 --- a/tests/AcDream.App.Tests/UI/Layout/TooltipSkinLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/TooltipSkinLiveDatTests.cs @@ -29,16 +29,26 @@ public sealed class TooltipSkinLiveDatTests Assert.NotNull(tree); int textChildren = 0; - void Walk(ElementInfo e) + void Walk(ElementInfo e, uint parentId) { if (e.Id == 0x10000396u && e.Type == 12) { textChildren++; Assert.Equal(256, e.MaxWidth); + // Retail wraps at (256 - margL - margR) and draws inset by + // the margins — the CA5 owner-screenshot delta (one extra + // word on line one + text flush against the right border). + // All four skins author L=2/R=2; skin 0x10000398 alone + // authors no vertical margins (live-DAT probe 2026-08-24). + Assert.Equal(2, e.MarginLeft); + Assert.Equal(2, e.MarginRight); + int vertical = parentId == 0x10000398u ? 0 : 2; + Assert.Equal(vertical, e.MarginTop); + Assert.Equal(vertical, e.MarginBottom); } - foreach (var c in e.Children) Walk(c); + foreach (var c in e.Children) Walk(c, e.Id); } - Walk(tree!); + Walk(tree!, 0); Assert.True(textChildren >= 4, $"expected the text child under all four popup skins, found {textChildren}"); } From 8fd3d1a9f0f2e9b2dfbba3e79320b5240ffde603 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 19:20:43 +0200 Subject: [PATCH 26/89] =?UTF-8?q?fix(ui):=20retail=20scrollbar=20parity=20?= =?UTF-8?q?=E2=80=94=20button=20seating,=20full-track=20thumb,=20hover/pre?= =?UTF-8?q?ssed=20states?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner report (2026-08-24): our scrollbar arrows pointed the wrong way, the thumb vanished when there was nothing to scroll, and neither the thumb nor the arrow buttons reacted to hover/press. All three are one retail mechanism we had not ported: 1. Seating: UIElement_Scrollbar::UpdateScrollingArea @0x00470AA0 moves the INCREMENT designee (attribute 0x77) to the top/left corner and the DECREMENT designee (0x78) to the bottom/right, ignoring authored positions. The vertical base skin (0x10000455 in layout 0x2100003E) authors the DOWN-arrow decrement at Y=0 and the UP-arrow increment at Y=32 (live-DAT probed; sprite art visually verified from decoded PNGs), so our authored-Y ordering drew both arrows upside down. DatWidgetFactory now seats by designation; the hand-wired sites (CharacterStatController, ExternalContainerController, the Config/Vendor menu chrome) share the new RetailScrollbarChrome catalog instead of local constants. 2. Full-track thumb: UpdateLayout @0x004710d0 sizes the thumb from proportion attribute 0x88, which DEFAULTS to 1.0 — a content-fits bar shows a thumb filling the whole track; disabled only removes input and the page regions. Our draw skipped the thumb entirely on !HasOverflow. 3. States: every arrow button and thumb slice authors Normal (red gem / dark navy), Normal_rollover (amber gem / bright blue) and Normal_pressed (gold highlight / dark) media. The widget now tracks thumb hover and selects rollover media on hover and pressed media while dragging; the factory extracts the thumb-state media for both the 3-slice and single-sprite thumb shapes. ScrollbarSkinLiveDatTests pins the designations and state media against the installed DAT so a revision or importer regression fails loudly. Co-Authored-By: Claude Fable 5 --- .../UI/Layout/CharacterStatController.cs | 18 +-- .../UI/Layout/ConfigOptionsPageController.cs | 7 +- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 59 +++++-- .../UI/Layout/ExternalContainerController.cs | 11 +- .../UI/Layout/VendorUiController.cs | 10 +- src/AcDream.App/UI/RetailScrollbarChrome.cs | 108 +++++++++++++ src/AcDream.App/UI/UiScrollbar.cs | 145 +++++++++++++++--- .../UI/Layout/CharacterStatControllerTests.cs | 8 +- .../UI/Layout/ChatLayoutConformanceTests.cs | 10 +- .../UI/Layout/DatWidgetFactoryTests.cs | 60 ++++++++ .../UI/Layout/ScrollbarSkinLiveDatTests.cs | 67 ++++++++ .../AcDream.App.Tests/UI/UiScrollbarTests.cs | 102 ++++++++++++ 12 files changed, 541 insertions(+), 64 deletions(-) create mode 100644 src/AcDream.App/UI/RetailScrollbarChrome.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/ScrollbarSkinLiveDatTests.cs diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index d29dfdc4..0cdfad33 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -137,13 +137,10 @@ public static class CharacterStatController private const uint SkillHeaderUnusableSprite = 0x06000F89u; private const uint RowHighlightSprite = 0x06001397u; - // Scrollbar chrome from base layout 0x2100003E, shared with chat/inventory. - private const uint ScrollTrackSprite = 0x06004C5Fu; - private const uint ScrollThumbSprite = 0x06004C63u; - private const uint ScrollThumbTop = 0x06004C60u; - private const uint ScrollThumbBot = 0x06004C66u; - private const uint ScrollUpSprite = 0x06004C69u; - private const uint ScrollDownSprite = 0x06004C6Cu; + // Scrollbar chrome from base layout 0x2100003E, shared with chat/ + // inventory — sprite set + retail button seating live in + // RetailScrollbarChrome (2026-08-24: the previous local constants seated + // the DOWN-arrow art on the top button). private enum CharacterStatTab { @@ -681,12 +678,7 @@ public static class CharacterStatController Func spriteResolve) { bar.SpriteResolve = id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); }; - bar.TrackSprite = ScrollTrackSprite; - bar.ThumbSprite = ScrollThumbSprite; - bar.ThumbTopSprite = ScrollThumbTop; - bar.ThumbBotSprite = ScrollThumbBot; - bar.UpSprite = ScrollUpSprite; - bar.DownSprite = ScrollDownSprite; + RetailScrollbarChrome.ApplyVertical(bar); } private static float SkillViewportWidth(UiElement statList, UiScrollbar? bar) diff --git a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs index 10d2f26f..be320d65 100644 --- a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs +++ b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs @@ -272,8 +272,11 @@ public static class ConfigOptionsPageController public const uint ScrollThumbTop = 0x06004C60u; public const uint ScrollThumb = 0x06004C63u; public const uint ScrollThumbBottom = 0x06004C66u; - public const uint ScrollUp = 0x06004C69u; - public const uint ScrollDown = 0x06004C6Cu; + // Retail seating (UpdateScrollingArea @0x00470AA0): the top slot + // takes the INCREMENT designee's UP-arrow art, the bottom the + // DECREMENT designee's DOWN-arrow (see RetailScrollbarChrome). + public const uint ScrollUp = RetailScrollbarChrome.UpNormal; + public const uint ScrollDown = RetailScrollbarChrome.DownNormal; } /// Applies + geometry to a diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index bb12f3fb..2e5f7a0b 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -232,16 +232,28 @@ public static class DatWidgetFactory uint decrementId = ReferencedElementId(info, 0x78u); ElementInfo? increment = info.Children.FirstOrDefault(child => child.Id == incrementId); ElementInfo? decrement = info.Children.FirstOrDefault(child => child.Id == decrementId); - ElementInfo? leadingButton = new[] { increment, decrement } - .Where(child => child is not null) - .OrderBy(child => bar.Horizontal ? child!.X : child!.Y) - .ThenBy(child => child!.ReadOrder) - .FirstOrDefault(); - ElementInfo? trailingButton = new[] { increment, decrement } - .Where(child => child is not null) - .OrderByDescending(child => bar.Horizontal ? child!.X : child!.Y) - .ThenByDescending(child => child!.ReadOrder) - .FirstOrDefault(); + + // Seat by DESIGNATION, not authored position: retail's + // UpdateScrollingArea @0x00470AA0 moves the increment designee to the + // top/left corner (MoveTo(0,0)) and the decrement designee to the + // bottom/right corner regardless of where the layout drew them. The + // vertical base skin authors the DOWN-arrow (0x10000071) at Y=0 and + // the UP-arrow (0x10000072) at Y=32, so the previous authored-Y + // ordering rendered both arrows upside down (2026-08-24 owner + // report). Fall back to authored order only when the designations + // are missing. + ElementInfo? leadingButton = increment; + ElementInfo? trailingButton = decrement; + if (leadingButton is null && trailingButton is null) + { + ElementInfo[] typeOneChildren = info.Children + .Where(child => child.Type == 1u && child.Id != 1u) + .OrderBy(child => bar.Horizontal ? child.X : child.Y) + .ThenBy(child => child.ReadOrder) + .ToArray(); + leadingButton = typeOneChildren.FirstOrDefault(); + trailingButton = typeOneChildren.Length > 1 ? typeOneChildren[^1] : null; + } bar.UpSprite = ButtonStateImage(leadingButton, "Normal"); bar.UpRolloverSprite = ButtonStateImage(leadingButton, "Normal_rollover"); bar.UpPressedSprite = ButtonStateImage(leadingButton, "Normal_pressed"); @@ -323,9 +335,24 @@ public static class DatWidgetFactory .OrderBy(child => child.Y) .ThenBy(child => child.ReadOrder) .ToArray(); - if (slices.Length > 0) bar.ThumbTopSprite = DefaultImage(slices[0]); - if (slices.Length > 1) bar.ThumbSprite = DefaultImage(slices[1]); - if (slices.Length > 2) bar.ThumbBotSprite = DefaultImage(slices[^1]); + if (slices.Length > 0) + { + bar.ThumbTopSprite = ButtonStateImage(slices[0], "Normal"); + bar.ThumbTopRolloverSprite = ButtonStateImage(slices[0], "Normal_rollover"); + bar.ThumbTopPressedSprite = ButtonStateImage(slices[0], "Normal_pressed"); + } + if (slices.Length > 1) + { + bar.ThumbSprite = ButtonStateImage(slices[1], "Normal"); + bar.ThumbRolloverSprite = ButtonStateImage(slices[1], "Normal_rollover"); + bar.ThumbPressedSprite = ButtonStateImage(slices[1], "Normal_pressed"); + } + if (slices.Length > 2) + { + bar.ThumbBotSprite = ButtonStateImage(slices[^1], "Normal"); + bar.ThumbBotRolloverSprite = ButtonStateImage(slices[^1], "Normal_rollover"); + bar.ThumbBotPressedSprite = ButtonStateImage(slices[^1], "Normal_pressed"); + } // R3-4/R3-7 (Campaign CC gate round 1 re-test 2): retail authors // TWO distinct thumb shapes for UIElement_Scrollbar (Type 11) — @@ -350,7 +377,11 @@ public static class DatWidgetFactory // slice children (chat) is unaffected since `slices.Length == 0` // is false for that shape. if (slices.Length == 0) - bar.ThumbSprite = DefaultImage(thumb); + { + bar.ThumbSprite = ButtonStateImage(thumb, "Normal"); + bar.ThumbRolloverSprite = ButtonStateImage(thumb, "Normal_rollover"); + bar.ThumbPressedSprite = ButtonStateImage(thumb, "Normal_pressed"); + } } return bar; diff --git a/src/AcDream.App/UI/Layout/ExternalContainerController.cs b/src/AcDream.App/UI/Layout/ExternalContainerController.cs index db66e5ed..75d9a9f5 100644 --- a/src/AcDream.App/UI/Layout/ExternalContainerController.cs +++ b/src/AcDream.App/UI/Layout/ExternalContainerController.cs @@ -98,13 +98,10 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine scrollbar.SpriteResolve ??= _contentsList.SpriteResolve; // Horizontal base LayoutDesc 0x2100003E media. The compatibility // factory treats all horizontal bars as scalar controls; this panel - // binds the authored model sprites explicitly. - scrollbar.TrackSprite = 0x06004C7Fu; - scrollbar.ThumbTopSprite = 0x06004C80u; - scrollbar.ThumbSprite = 0x06004C83u; - scrollbar.ThumbBotSprite = 0x06004C86u; - scrollbar.DownSprite = 0x06004C89u; - scrollbar.UpSprite = 0x06004C8Cu; + // binds the authored model sprites explicitly — full state set + + // retail seating from RetailScrollbarChrome (increment/left = + // 0x06004C8C, decrement/right = 0x06004C89). + RetailScrollbarChrome.ApplyHorizontal(scrollbar); } BindClose(layout, RequestClose); diff --git a/src/AcDream.App/UI/Layout/VendorUiController.cs b/src/AcDream.App/UI/Layout/VendorUiController.cs index b1ddf49e..f6dd3231 100644 --- a/src/AcDream.App/UI/Layout/VendorUiController.cs +++ b/src/AcDream.App/UI/Layout/VendorUiController.cs @@ -181,8 +181,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// list, with a real thumb/up/down-button subtree matching /// 's own shape exactly: thumb caps /// 0x06004C60/63/66, up button (element - /// 0x10000071) 0x06004C69/6A/6B, down button - /// (element 0x10000072) 0x06004C6C/6D/6E, + /// 0x10000072, retail-seated on top) 0x06004C6C/6D/6E, down button + /// (element 0x10000071, retail-seated on the bottom) 0x06004C69/6A/6B, /// track 0x06004C5F). With 18 authored categories and only 6 /// visible rows, retail's actual rendering is a single scrolling column /// (matching the user's reference screenshot: ~visible rows + scrollbar + @@ -288,8 +288,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag private const uint TypeMenuScrollThumbTopSprite = 0x06004C60u; private const uint TypeMenuScrollThumbSprite = 0x06004C63u; private const uint TypeMenuScrollThumbBottomSprite = 0x06004C66u; - private const uint TypeMenuScrollUpSprite = 0x06004C69u; - private const uint TypeMenuScrollDownSprite = 0x06004C6Cu; + // Retail seating (UpdateScrollingArea @0x00470AA0): top = the INCREMENT + // designee's UP-arrow art, bottom = the DECREMENT designee's DOWN-arrow. + private const uint TypeMenuScrollUpSprite = RetailScrollbarChrome.UpNormal; + private const uint TypeMenuScrollDownSprite = RetailScrollbarChrome.DownNormal; /// /// Retail's ordered category table, transcribed verbatim from diff --git a/src/AcDream.App/UI/RetailScrollbarChrome.cs b/src/AcDream.App/UI/RetailScrollbarChrome.cs new file mode 100644 index 00000000..0dc3623d --- /dev/null +++ b/src/AcDream.App/UI/RetailScrollbarChrome.cs @@ -0,0 +1,108 @@ +namespace AcDream.App.UI; + +/// +/// The base scrollbar skin from LayoutDesc 0x2100003E, seated the way +/// retail seats it at runtime. UIElement_Scrollbar::UpdateScrollingArea +/// @0x00470AA0 ignores the buttons' authored positions: it moves the +/// INCREMENT button (attribute 0x77) to the top/left corner +/// (MoveTo(0,0)) and the DECREMENT button (attribute 0x78) to +/// the bottom/right corner. The vertical skin (element 0x10000455) +/// designates 0x77=0x10000072 (the UP-arrow art, authored at Y=32) +/// and 0x78=0x10000071 (the DOWN-arrow art, authored at Y=0) — so +/// seating by authored Y renders both arrows upside down (the 2026-08-24 +/// owner report). Each button and thumb slice authors three states: +/// Normal (red center gem / dark navy thumb), Normal_rollover (amber gem / +/// bright blue thumb), Normal_pressed (gold highlight gem / dark thumb) — +/// all live-DAT probed 2026-08-24. +/// +internal static class RetailScrollbarChrome +{ + // ── Vertical skin (element 0x10000455, 16 px) ──────────────────────── + internal const uint Track = 0x06004C5Fu; + + /// Top button = the INCREMENT designee 0x10000072 (up arrow). + internal const uint UpNormal = 0x06004C6Cu; + internal const uint UpRollover = 0x06004C6Du; + internal const uint UpPressed = 0x06004C6Eu; + + /// Bottom button = the DECREMENT designee 0x10000071 (down arrow). + internal const uint DownNormal = 0x06004C69u; + internal const uint DownRollover = 0x06004C6Au; + internal const uint DownPressed = 0x06004C6Bu; + + internal const uint ThumbTopNormal = 0x06004C60u; + internal const uint ThumbTopRollover = 0x06004C61u; + internal const uint ThumbTopPressed = 0x06004C62u; + internal const uint ThumbMidNormal = 0x06004C63u; + internal const uint ThumbMidRollover = 0x06004C64u; + internal const uint ThumbMidPressed = 0x06004C65u; + internal const uint ThumbBotNormal = 0x06004C66u; + internal const uint ThumbBotRollover = 0x06004C67u; + internal const uint ThumbBotPressed = 0x06004C68u; + + // ── Horizontal skin (element 0x1000036D, 16 px) ────────────────────── + internal const uint HTrack = 0x06004C7Fu; + + /// Left button = the INCREMENT designee 0x1000036C. + internal const uint LeftNormal = 0x06004C8Cu; + internal const uint LeftRollover = 0x06004C8Du; + internal const uint LeftPressed = 0x06004C8Eu; + + /// Right button = the DECREMENT designee 0x1000036B. + internal const uint RightNormal = 0x06004C89u; + internal const uint RightRollover = 0x06004C8Au; + internal const uint RightPressed = 0x06004C8Bu; + + internal const uint HThumbTopNormal = 0x06004C80u; + internal const uint HThumbTopRollover = 0x06004C81u; + internal const uint HThumbTopPressed = 0x06004C82u; + internal const uint HThumbMidNormal = 0x06004C83u; + internal const uint HThumbMidRollover = 0x06004C84u; + internal const uint HThumbMidPressed = 0x06004C85u; + internal const uint HThumbBotNormal = 0x06004C86u; + internal const uint HThumbBotRollover = 0x06004C87u; + internal const uint HThumbBotPressed = 0x06004C88u; + + /// Wires the full retail vertical skin onto . + internal static void ApplyVertical(UiScrollbar bar) + { + bar.TrackSprite = Track; + bar.UpSprite = UpNormal; + bar.UpRolloverSprite = UpRollover; + bar.UpPressedSprite = UpPressed; + bar.DownSprite = DownNormal; + bar.DownRolloverSprite = DownRollover; + bar.DownPressedSprite = DownPressed; + bar.ThumbTopSprite = ThumbTopNormal; + bar.ThumbTopRolloverSprite = ThumbTopRollover; + bar.ThumbTopPressedSprite = ThumbTopPressed; + bar.ThumbSprite = ThumbMidNormal; + bar.ThumbRolloverSprite = ThumbMidRollover; + bar.ThumbPressedSprite = ThumbMidPressed; + bar.ThumbBotSprite = ThumbBotNormal; + bar.ThumbBotRolloverSprite = ThumbBotRollover; + bar.ThumbBotPressedSprite = ThumbBotPressed; + } + + /// Wires the full retail horizontal skin onto . + /// The leading () slot is the LEFT edge. + internal static void ApplyHorizontal(UiScrollbar bar) + { + bar.TrackSprite = HTrack; + bar.UpSprite = LeftNormal; + bar.UpRolloverSprite = LeftRollover; + bar.UpPressedSprite = LeftPressed; + bar.DownSprite = RightNormal; + bar.DownRolloverSprite = RightRollover; + bar.DownPressedSprite = RightPressed; + bar.ThumbTopSprite = HThumbTopNormal; + bar.ThumbTopRolloverSprite = HThumbTopRollover; + bar.ThumbTopPressedSprite = HThumbTopPressed; + bar.ThumbSprite = HThumbMidNormal; + bar.ThumbRolloverSprite = HThumbMidRollover; + bar.ThumbPressedSprite = HThumbMidPressed; + bar.ThumbBotSprite = HThumbBotNormal; + bar.ThumbBotRolloverSprite = HThumbBotRollover; + bar.ThumbBotPressedSprite = HThumbBotPressed; + } +} diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs index 3892bdac..3ecef835 100644 --- a/src/AcDream.App/UI/UiScrollbar.cs +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -12,9 +12,13 @@ namespace AcDream.App.UI; /// Dat element ids (chat LayoutDesc 0x2100006F, Campaign CH slice CH6a — retired the /// wrong 0x21000006 import): track 0x10000012 (X=384 Y=0 W=16 H=73 relative to the /// transcript panel). The track is instanced from base layout 0x2100003E which contains -/// the full scrollbar widget with distinct up/down button children: -/// Up button element 0x10000071 — Y=0, 16×16, Normal sprite 0x06004C69. -/// Down button element 0x10000072 — Y=32, 16×16, Normal sprite 0x06004C6C. +/// the full scrollbar widget with distinct button children. IMPORTANT — retail seats +/// the buttons by DESIGNATION, not authored position: UpdateScrollingArea +/// @0x00470AA0 moves the INCREMENT designee (attribute 0x77 = 0x10000072, the +/// UP-arrow art 0x06004C6C, authored at Y=32) to the TOP and the DECREMENT designee +/// (attribute 0x78 = 0x10000071, the DOWN-arrow art 0x06004C69, authored at Y=0) to +/// the BOTTOM. Seating by authored Y renders both arrows upside down (2026-08-24 +/// owner report). holds the correctly-seated set. /// Track body sprite: 0x06004C5F (48px tall in the base template; stretched to H=68 in chat). /// Thumb is a 3-slice: top cap 0x06004C60, middle 0x06004C63, bottom cap 0x06004C66. /// The widget reproduces referenced button children procedurally and uses their @@ -118,10 +122,31 @@ public sealed class UiScrollbar : UiElement /// Thumb 3-slice BOTTOM cap sprite id (0x06004C66, 3px tall). public uint ThumbBotSprite { get; set; } - /// Up-arrow button sprite id (0x06004C69 Normal state, element 0x10000071). + /// + /// Thumb rollover/pressed media. Retail's thumb (the scrollbar's + /// structural child 1 — UIElement_Scrollbar::UpdateLayout + /// @0x004710d0 binds m_pWidget = GetChild(this, 1)) authors + /// three states per slice: Normal (dark navy), Normal_rollover (bright + /// blue highlight), Normal_pressed (dark again). Hovering the thumb + /// highlights it; holding a drag shows the pressed art, which is + /// authored to look like the resting color — the 2026-08-24 owner + /// report's "highlights on hover, returns to the original color while + /// you hold". Zero ids fall back to the Normal sprites. + /// + public uint ThumbRolloverSprite { get; set; } + public uint ThumbPressedSprite { get; set; } + public uint ThumbTopRolloverSprite { get; set; } + public uint ThumbTopPressedSprite { get; set; } + public uint ThumbBotRolloverSprite { get; set; } + public uint ThumbBotPressedSprite { get; set; } + + /// Top/leading button sprite id. Retail seats the INCREMENT + /// designee here (vertical base skin: 0x10000072's UP-arrow art + /// 0x06004C6C) — see the class remarks and . public uint UpSprite { get; set; } - /// Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072). + /// Bottom/trailing button sprite id. Retail seats the DECREMENT + /// designee here (vertical base skin: 0x10000071's DOWN-arrow art 0x06004C69). public uint DownSprite { get; set; } /// Rollover and pressed media for the start/decrement button. @@ -155,6 +180,7 @@ public sealed class UiScrollbar : UiElement private const float CapH = 3f; private bool _draggingThumb; + private bool _hoveredThumb; private float _dragOffsetY; private float _dragOffsetX; private EndButton _hoveredButton; @@ -245,7 +271,7 @@ public sealed class UiScrollbar : UiElement } float travel = MathF.Max(0f, Width - thumbWidth); float x = travel * ScalarPosition; - DrawSprite(ctx, resolve, ThumbSprite, x, 0f, thumbWidth, Height); + DrawSprite(ctx, resolve, ActiveThumbSprite, x, 0f, thumbWidth, Height); return; } @@ -269,20 +295,24 @@ public sealed class UiScrollbar : UiElement DrawSprite(ctx, resolve, ActiveEndSprite, 0f, Height - incrementExtent, Width, incrementExtent); - // Thumb — only when content overflows the view. Retail 3-slice: top cap + - // tiled middle + bottom cap (base layout 0x2100003E thumb sub-elements - // 0x10000364/65/66). Falls back to a single tiled middle if the caps are unset + // Thumb — drawn even with nothing to scroll: retail's proportion + // attribute 0x88 defaults to 1.0 (UpdateLayout @0x004710d0), so a + // content-fits scrollbar shows a thumb FILLING the whole track + // (ThumbRatio clamps to 1 → full trackLen); only the page-click + // regions and input go away with the disabled state. Retail 3-slice: + // top cap + tiled middle + bottom cap (base layout 0x2100003E thumb + // sub-elements 0x10000364/65/66), each with hover/pressed state + // media. Falls back to a single tiled middle if the caps are unset // or the thumb is too short to hold both caps. - if (m.HasOverflow) { float trackTop = decrementExtent; float trackLen = MathF.Max(0f, Height - decrementExtent - incrementExtent); var (ty, th) = ThumbRect(m, trackTop, trackLen); if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && th >= 2f * CapH) { - DrawSprite(ctx, resolve, ThumbTopSprite, 0f, ty, Width, CapH); - DrawTiled(ctx, resolve, ThumbSprite, 0f, ty + CapH, Width, th - 2f * CapH); - DrawSprite(ctx, resolve, ThumbBotSprite, 0f, ty + th - CapH, Width, CapH); + DrawSprite(ctx, resolve, ActiveThumbTopSprite, 0f, ty, Width, CapH); + DrawTiled(ctx, resolve, ActiveThumbSprite, 0f, ty + CapH, Width, th - 2f * CapH); + DrawSprite(ctx, resolve, ActiveThumbBotSprite, 0f, ty + th - CapH, Width, CapH); } else { @@ -296,11 +326,29 @@ public sealed class UiScrollbar : UiElement // (~9 repeats on Summary's overview bar, ~2 on Skills, per // the live capture). DrawThumbMarker draws exactly ONE // instance at its own native size. - DrawThumbMarker(ctx, resolve, ThumbSprite, 0f, ty, Width, th, vertical: true); + DrawThumbMarker(ctx, resolve, ActiveThumbSprite, 0f, ty, Width, th, vertical: true); } } } + /// + /// Picks a thumb slice's sprite for the current interaction state: + /// dragging → pressed media, hovered → rollover media, else normal — + /// mirroring retail's authored Normal/Normal_rollover/Normal_pressed + /// state machine on the thumb's slice children. Zero-id media fall + /// back to the normal sprite. + /// + private uint ActiveThumb(uint normal, uint rollover, uint pressed) + => _draggingThumb && pressed != 0u + ? pressed + : _hoveredThumb && !_draggingThumb && rollover != 0u + ? rollover + : normal; + + private uint ActiveThumbSprite => ActiveThumb(ThumbSprite, ThumbRolloverSprite, ThumbPressedSprite); + private uint ActiveThumbTopSprite => ActiveThumb(ThumbTopSprite, ThumbTopRolloverSprite, ThumbTopPressedSprite); + private uint ActiveThumbBotSprite => ActiveThumb(ThumbBotSprite, ThumbBotRolloverSprite, ThumbBotPressedSprite); + /// /// R4-2 (Campaign CC gate round 1 re-test 3): draws ONE instance of a /// single-sprite scrollbar thumb at its own native size, centered @@ -351,21 +399,22 @@ public sealed class UiScrollbar : UiElement DrawSprite(ctx, resolve, ActiveStartSprite, 0f, 0f, decrementExtent, Height); DrawSprite(ctx, resolve, ActiveEndSprite, Width - incrementExtent, 0f, incrementExtent, Height); - if (!model.HasOverflow) return; + // Content-fits bars draw a FULL-track thumb, same as the vertical + // path (retail proportion attribute 0x88 defaults to 1.0). float trackLeft = decrementExtent; float trackLength = MathF.Max(0f, Width - decrementExtent - incrementExtent); var (tx, tw) = ThumbRect(model, trackLeft, trackLength); if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && tw >= 2f * CapH) { - DrawSprite(ctx, resolve, ThumbTopSprite, tx, 0f, CapH, Height); - DrawTiled(ctx, resolve, ThumbSprite, tx + CapH, 0f, tw - 2f * CapH, Height); - DrawSprite(ctx, resolve, ThumbBotSprite, tx + tw - CapH, 0f, CapH, Height); + DrawSprite(ctx, resolve, ActiveThumbTopSprite, tx, 0f, CapH, Height); + DrawTiled(ctx, resolve, ActiveThumbSprite, tx + CapH, 0f, tw - 2f * CapH, Height); + DrawSprite(ctx, resolve, ActiveThumbBotSprite, tx + tw - CapH, 0f, CapH, Height); } else { // R4-2: horizontal counterpart of the vertical fallback above. - DrawThumbMarker(ctx, resolve, ThumbSprite, tx, 0f, tw, Height, vertical: false); + DrawThumbMarker(ctx, resolve, ActiveThumbSprite, tx, 0f, tw, Height, vertical: false); } } @@ -390,7 +439,7 @@ public sealed class UiScrollbar : UiElement float thumbHeight = ScalarThumbExtent(resolve, Height); float travel = MathF.Max(0f, Height - thumbHeight); float y = travel * ScalarPosition; - DrawSprite(ctx, resolve, ThumbSprite, 0f, y, Width, thumbHeight); + DrawSprite(ctx, resolve, ActiveThumbSprite, 0f, y, Width, thumbHeight); } /// Draw a sprite stretched 1:1 to the dest rect. @@ -474,6 +523,7 @@ public sealed class UiScrollbar : UiElement if (IsModelDisabled) { _draggingThumb = false; + _hoveredThumb = false; _hoveredButton = EndButton.None; _pressedButton = EndButton.None; return false; @@ -482,15 +532,20 @@ public sealed class UiScrollbar : UiElement if (e.Type == UiEventType.HoverEnter) { _hoveredButton = ButtonAt(e.Data1, e.Data2); + _hoveredThumb = ThumbAt(e.Data1, e.Data2); return true; } if (e.Type == UiEventType.HoverLeave) { _hoveredButton = EndButton.None; + _hoveredThumb = false; return true; } if (e.Type == UiEventType.MouseMove) + { _hoveredButton = ButtonAt(e.Data1, e.Data2); + _hoveredThumb = ThumbAt(e.Data1, e.Data2); + } // Fix round F11: retail's chargen shade scrollbar (0x10000321) is // authored VERTICAL (measured against the installed dat), but a @@ -758,6 +813,53 @@ public sealed class UiScrollbar : UiElement ScalarChanged?.Invoke(ScalarPosition); } + /// + /// Whether the point sits on the thumb, for hover-state tracking — + /// covers all four modes (model/scalar × vertical/horizontal) using the + /// SAME geometry the matching draw and MouseDown paths compute. + /// + private bool ThumbAt(float x, float y) + { + if (x < 0f || x >= Width || y < 0f || y >= Height) + return false; + + if (ScalarChanged is not null) + { + if (Horizontal) + { + float thumbWidth = ScalarThumbWidth(SpriteResolve); + float thumbX = MathF.Max(0f, Width - thumbWidth) * ScalarPosition; + return x >= thumbX && x <= thumbX + thumbWidth; + } + float thumbHeight = ScalarThumbExtent(SpriteResolve, Height); + float thumbY = MathF.Max(0f, Height - thumbHeight) * ScalarPosition; + return y >= thumbY && y <= thumbY + thumbHeight; + } + + if (Model is not { } m) return false; + + if (Horizontal) + { + float trackLeft = AxisExtent(DecrementButtonExtent, Width); + float trackLength = MathF.Max( + 0f, + Width + - AxisExtent(DecrementButtonExtent, Width) + - AxisExtent(IncrementButtonExtent, Width)); + var (tx, tw) = ThumbRect(m, trackLeft, trackLength); + return x >= tx && x <= tx + tw; + } + + float trackTop = AxisExtent(DecrementButtonExtent, Height); + float trackLen = MathF.Max( + 0f, + Height + - AxisExtent(DecrementButtonExtent, Height) + - AxisExtent(IncrementButtonExtent, Height)); + var (ty, th) = ThumbRect(m, trackTop, trackLen); + return y >= ty && y <= ty + th; + } + private EndButton ButtonAt(float x, float y) { if (x < 0f || x >= Width || y < 0f || y >= Height) @@ -799,6 +901,9 @@ public sealed class UiScrollbar : UiElement internal uint ActiveStartSpriteForTest => ActiveStartSprite; internal uint ActiveEndSpriteForTest => ActiveEndSprite; + internal uint ActiveThumbSpriteForTest => ActiveThumbSprite; + internal uint ActiveThumbTopSpriteForTest => ActiveThumbTopSprite; + internal uint ActiveThumbBotSpriteForTest => ActiveThumbBotSprite; private static float AxisExtent(float authoredExtent, float axisLength) => Math.Clamp(authoredExtent, 0f, MathF.Max(0f, axisLength)); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index a62590b0..96e142b2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -1945,8 +1945,12 @@ public class CharacterStatControllerTests Assert.NotNull(scrollbar.Model); Assert.NotNull(scrollbar.SpriteResolve); Assert.Equal(0x06004C5Fu, scrollbar.TrackSprite); - Assert.Equal(0x06004C69u, scrollbar.UpSprite); - Assert.Equal(0x06004C6Cu, scrollbar.DownSprite); + // Retail seating (2026-08-24): top = the INCREMENT designee's + // UP-arrow art, bottom = the DECREMENT designee's DOWN-arrow. + Assert.Equal(RetailScrollbarChrome.UpNormal, scrollbar.UpSprite); + Assert.Equal(RetailScrollbarChrome.DownNormal, scrollbar.DownSprite); + Assert.Equal(RetailScrollbarChrome.UpRollover, scrollbar.UpRolloverSprite); + Assert.Equal(RetailScrollbarChrome.ThumbMidRollover, scrollbar.ThumbRolloverSprite); } // ── Helpers ────────────────────────────────────────────────────────────── diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs index 28558957..a32066da 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs @@ -287,8 +287,14 @@ public class ChatLayoutConformanceTests Assert.Equal(0x06004C60u, scrollbar.ThumbTopSprite); Assert.Equal(0x06004C63u, scrollbar.ThumbSprite); Assert.Equal(0x06004C66u, scrollbar.ThumbBotSprite); - Assert.Equal(0x06004C69u, scrollbar.UpSprite); - Assert.Equal(0x06004C6Cu, scrollbar.DownSprite); + // Retail seating (2026-08-24): UpdateScrollingArea @0x00470AA0 puts + // the INCREMENT designee (0x10000072, UP-arrow art 0x06004C6C) on the + // top button and the DECREMENT designee (0x10000071, DOWN-arrow + // 0x06004C69) on the bottom, ignoring authored Y. + Assert.Equal(0x06004C6Cu, scrollbar.UpSprite); + Assert.Equal(0x06004C69u, scrollbar.DownSprite); + Assert.Equal(0x06004C64u, scrollbar.ThumbRolloverSprite); + Assert.Equal(0x06004C65u, scrollbar.ThumbPressedSprite); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 1ca6c0c2..7644d6af 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -786,6 +786,61 @@ public class DatWidgetFactoryTests Assert.Equal(Thumb, bar.ThumbSprite); Assert.Equal(0u, bar.ThumbTopSprite); Assert.Equal(0u, bar.ThumbBotSprite); + // 2026-08-24: the thumb's own rollover/pressed media survive onto + // the widget (hover highlight / held-drag art). + Assert.Equal(0x06005A12u, bar.ThumbRolloverSprite); + Assert.Equal(0x06005A13u, bar.ThumbPressedSprite); + } + + /// + /// 2026-08-24 owner report ("the arrows point in the wrong direction"): + /// retail seats scrollbar buttons by DESIGNATION — + /// UIElement_Scrollbar::UpdateScrollingArea @0x00470AA0 moves the + /// INCREMENT designee (attribute 0x77) to the top corner and the + /// DECREMENT designee (0x78) to the bottom corner regardless of authored + /// position. The real vertical base skin (0x10000455 in layout + /// 0x2100003E) authors the DOWN-arrow decrement at Y=0 and the UP-arrow + /// increment at Y=32, so the previous authored-Y ordering put the + /// down-arrow art on the TOP button. + /// + [Fact] + public void Type11_VerticalScrollbar_SeatsButtonsByDesignation_NotAuthoredPosition() + { + const uint DecrementId = 0x10000071u; // DOWN arrow, authored at Y=0 + const uint IncrementId = 0x10000072u; // UP arrow, authored at Y=32 + var decrement = new ElementInfo { Id = DecrementId, Type = 1u, Y = 0f, Width = 16f, Height = 16f }; + decrement.StateMedia["Normal"] = (0x06004C69u, 1); + decrement.StateMedia["Normal_rollover"] = (0x06004C6Au, 1); + decrement.StateMedia["Normal_pressed"] = (0x06004C6Bu, 1); + var increment = new ElementInfo { Id = IncrementId, Type = 1u, Y = 32f, Width = 16f, Height = 16f }; + increment.StateMedia["Normal"] = (0x06004C6Cu, 1); + increment.StateMedia["Normal_rollover"] = (0x06004C6Du, 1); + increment.StateMedia["Normal_pressed"] = (0x06004C6Eu, 1); + + var info = new ElementInfo + { + Type = 11u, + Width = 16f, + Height = 48f, + Children = [decrement, increment], + }; + var state = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + state.Properties.Values[0x77u] = new UiPropertyValue + { Kind = UiPropertyKind.Enum, UnsignedValue = IncrementId }; + state.Properties.Values[0x78u] = new UiPropertyValue + { Kind = UiPropertyKind.Enum, UnsignedValue = DecrementId }; + info.States[UiStateInfo.DirectStateId] = state; + + var bar = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); + + // Top slot = the increment designee's UP-arrow media. + Assert.Equal(0x06004C6Cu, bar.UpSprite); + Assert.Equal(0x06004C6Du, bar.UpRolloverSprite); + Assert.Equal(0x06004C6Eu, bar.UpPressedSprite); + // Bottom slot = the decrement designee's DOWN-arrow media. + Assert.Equal(0x06004C69u, bar.DownSprite); + Assert.Equal(0x06004C6Au, bar.DownRolloverSprite); + Assert.Equal(0x06004C6Bu, bar.DownPressedSprite); } /// @@ -810,6 +865,8 @@ public class DatWidgetFactoryTests topCap.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal", Image = new UiImageMedia(Top, 1) }; var mid = new ElementInfo { Id = 0x10000365u, Type = 3u, Y = 3f, Width = 16f, Height = 10f }; mid.StateMedia["Normal"] = (Mid, 1); + mid.StateMedia["Normal_rollover"] = (0x06004C64u, 1); + mid.StateMedia["Normal_pressed"] = (0x06004C65u, 1); mid.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal", Image = new UiImageMedia(Mid, 1) }; var botCap = new ElementInfo { Id = 0x10000366u, Type = 3u, Y = 13f, Width = 16f, Height = 3f }; botCap.StateMedia["Normal"] = (Bot, 1); @@ -838,6 +895,9 @@ public class DatWidgetFactoryTests Assert.Equal(Top, bar.ThumbTopSprite); Assert.Equal(Mid, bar.ThumbSprite); Assert.Equal(Bot, bar.ThumbBotSprite); + // 2026-08-24: slice rollover/pressed media survive onto the widget. + Assert.Equal(0x06004C64u, bar.ThumbRolloverSprite); + Assert.Equal(0x06004C65u, bar.ThumbPressedSprite); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/ScrollbarSkinLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/ScrollbarSkinLiveDatTests.cs new file mode 100644 index 00000000..b3d13d9e --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ScrollbarSkinLiveDatTests.cs @@ -0,0 +1,67 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using DatReaderWriter; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// 2026-08-24 pin: the base scrollbar skin (layout 0x2100003E) +/// authors the button DESIGNATIONS that drive retail's runtime seating +/// (UIElement_Scrollbar::UpdateScrollingArea @0x00470AA0 moves the +/// increment designee to the top/left, the decrement designee to the +/// bottom/right, ignoring authored positions). The vertical skin +/// designates 0x77=0x10000072 (UP-arrow art) and 0x78=0x10000071 +/// (DOWN-arrow art) — seating by authored Y renders both arrows upside +/// down, the owner-reported bug. Also pins the three-state media sets +/// mirrors, so a DAT revision or +/// importer regression that loses a state fails loudly. +/// +[Trait("Lane", "InstalledDat")] +public sealed class ScrollbarSkinLiveDatTests +{ + private static string DatDirectory => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + + [InstalledDatFact] + public void VerticalBaseSkin_DesignatesUpArrowAsIncrement_WithThreeStateMedia() + { + using var dats = new DatCollection( + DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + ElementInfo? tree = LayoutImporter.ImportInfos(dats, 0x2100003Eu); + Assert.NotNull(tree); + + ElementInfo bar = Assert.Single(Flatten(tree!), e => e.Id == 0x10000455u); + Assert.True(bar.TryGetEffectiveProperty(0x77u, out UiPropertyValue inc)); + Assert.True(bar.TryGetEffectiveProperty(0x78u, out UiPropertyValue dec)); + Assert.Equal(0x10000072u, inc.UnsignedValue); // increment = UP arrow (authored Y=32) + Assert.Equal(0x10000071u, dec.UnsignedValue); // decrement = DOWN arrow (authored Y=0) + + // Increment (top after seating): red-gem normal, amber rollover, + // highlight pressed — the RetailScrollbarChrome Up set. + ElementInfo up = Assert.Single(bar.Children, c => c.Id == 0x10000072u); + Assert.Equal(RetailScrollbarChrome.UpNormal, up.StateMedia["Normal"].File); + Assert.Equal(RetailScrollbarChrome.UpRollover, up.StateMedia["Normal_rollover"].File); + Assert.Equal(RetailScrollbarChrome.UpPressed, up.StateMedia["Normal_pressed"].File); + ElementInfo down = Assert.Single(bar.Children, c => c.Id == 0x10000071u); + Assert.Equal(RetailScrollbarChrome.DownNormal, down.StateMedia["Normal"].File); + Assert.Equal(RetailScrollbarChrome.DownRollover, down.StateMedia["Normal_rollover"].File); + Assert.Equal(RetailScrollbarChrome.DownPressed, down.StateMedia["Normal_pressed"].File); + + // Thumb (structural child 1) slices each author the three states. + ElementInfo thumb = Assert.Single(bar.Children, c => c.Id == 1u); + ElementInfo mid = Assert.Single(thumb.Children, c => c.Id == 0x10000365u); + Assert.Equal(RetailScrollbarChrome.ThumbMidNormal, mid.StateMedia["Normal"].File); + Assert.Equal(RetailScrollbarChrome.ThumbMidRollover, mid.StateMedia["Normal_rollover"].File); + Assert.Equal(RetailScrollbarChrome.ThumbMidPressed, mid.StateMedia["Normal_pressed"].File); + } + + private static IEnumerable Flatten(ElementInfo e) + { + yield return e; + foreach (var c in e.Children) + foreach (var d in Flatten(c)) + yield return d; + } +} diff --git a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs index 36c80c83..daa501a3 100644 --- a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs +++ b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs @@ -471,6 +471,108 @@ public class UiScrollbarTests Assert.Equal(expectedWidth, width, 3); } + /// + /// 2026-08-24 owner report: retail shows a thumb FILLING the whole + /// track when there is nothing to scroll — the proportion attribute + /// 0x88 defaults to 1.0 in UIElement_Scrollbar::UpdateLayout + /// @0x004710d0, so a content-fits bar sizes the widget to the full + /// scrolling area; only input goes away with the disabled state. The + /// previous draw skipped the thumb entirely on !HasOverflow. + /// + [Fact] + public void NoOverflow_DrawsAFullTrackThumb() + { + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(800f, 600f)); + var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f)); + + const uint topTex = 60u, midTex = 63u, botTex = 66u; + var model = new UiScrollable { ContentHeight = 150, ViewHeight = 150 }; + var bar = new UiScrollbar + { + Width = 16f, + Height = 200f, + SpriteResolve = id => id is topTex or midTex or botTex ? (id, 16, 3) : (0u, 0, 0), + ThumbTopSprite = topTex, + ThumbSprite = midTex, + ThumbBotSprite = botTex, + Model = model, + }; + Assert.True(bar.IsModelDisabled); + + bar.DrawSelfAndChildren(ctx); + + // Top cap sits at the top of the track (below the 16px up button)… + var top = Assert.Single( + renderer.DebugSpriteSegmentVerts, s => s.Texture == topTex); + float topMinY = Enumerable.Range(0, top.Verts.Count / 8) + .Min(i => top.Verts[i * 8 + 1]); + Assert.Equal(16f, topMinY, 1); + // …and the bottom cap ends at the bottom of the track (above the + // 16px down button) — a full-track thumb. + var bot = Assert.Single( + renderer.DebugSpriteSegmentVerts, s => s.Texture == botTex); + float botMaxY = Enumerable.Range(0, bot.Verts.Count / 8) + .Max(i => bot.Verts[i * 8 + 1]); + Assert.Equal(184f, botMaxY, 1); + } + + /// + /// 2026-08-24 owner report: hovering the thumb highlights it + /// (Normal_rollover media — bright blue on the base skin) and holding a + /// drag shows the pressed media (authored to look like the resting + /// color). Mirrors retail's authored three-state thumb slices. + /// + [Fact] + public void ThumbHoverAndDrag_SelectRolloverAndPressedMedia() + { + var model = new UiScrollable { ContentHeight = 200, ViewHeight = 150, LineHeight = 10 }; + var bar = new UiScrollbar + { + Width = 16f, + Height = 200f, + Model = model, + ThumbSprite = 1u, + ThumbRolloverSprite = 2u, + ThumbPressedSprite = 3u, + ThumbTopSprite = 10u, + ThumbTopRolloverSprite = 20u, + ThumbTopPressedSprite = 30u, + ThumbBotSprite = 100u, + ThumbBotRolloverSprite = 200u, + ThumbBotPressedSprite = 300u, + }; + // Track 16..184 (168px), ratio 0.75 → thumb 16..142 at position 0. + Assert.Equal(1u, bar.ActiveThumbSpriteForTest); + + // Hover over the thumb → rollover on every slice. + bar.OnEvent(new UiEvent(0u, bar, UiEventType.HoverEnter, Data1: 8, Data2: 50)); + Assert.Equal(2u, bar.ActiveThumbSpriteForTest); + Assert.Equal(20u, bar.ActiveThumbTopSpriteForTest); + Assert.Equal(200u, bar.ActiveThumbBotSpriteForTest); + + // Press and hold (drag) → pressed media. + bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 8, Data2: 50)); + Assert.True(bar.IsDragging); + Assert.Equal(3u, bar.ActiveThumbSpriteForTest); + Assert.Equal(30u, bar.ActiveThumbTopSpriteForTest); + Assert.Equal(300u, bar.ActiveThumbBotSpriteForTest); + + // Release while still over the thumb → back to the hover highlight. + bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 8, Data2: 50)); + Assert.Equal(2u, bar.ActiveThumbSpriteForTest); + + // Move to the track BELOW the thumb → back to normal. + bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 8, Data2: 170)); + Assert.Equal(1u, bar.ActiveThumbSpriteForTest); + + // Leave the bar entirely → normal. + bar.OnEvent(new UiEvent(0u, bar, UiEventType.HoverEnter, Data1: 8, Data2: 50)); + bar.OnEvent(new UiEvent(0u, bar, UiEventType.HoverLeave)); + Assert.Equal(1u, bar.ActiveThumbSpriteForTest); + } + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource { public IGpuFrame? CurrentFrame => null; From 2d6333f84c3d205856179aa85ba6f4d347fcd12c Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 19:54:28 +0200 Subject: [PATCH 27/89] fix(ui): disabled scrollbars keep retail hover hot-tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner report + live [ui-hover] probe (2026-08-24): hovering a content-fits scrollbar did nothing because IsModelDisabled made the whole bar hit-TRANSPARENT — every hover over it reported widget=. Retail's arrows and thumb are real child elements whose Normal_rollover hot-tracking keeps running while the scrollbar is disabled (UpdateLayout @0x004710d0 only hides the page-click regions, children 4-7, and — with attribute 0x79 — the whole bar); scrolling stays inert through geometry, not an input gate: a full-track thumb has zero travel and the line/page steps clamp against nothing. OnHitTest and the input path now gate on presentation visibility only. A visible disabled bar hover-highlights and consumes clicks without scrolling; a HideWhenDisabled bar stays inert. New root-level hover tests drive real UiRoot hit-test dispatch (bare widget + the mounted production character fixture) so this class of "state machine green, pointer never arrives" bug fails loudly. User-verified live 2026-08-24 ("bar works now"). Co-Authored-By: Claude Fable 5 --- src/AcDream.App/UI/UiScrollbar.cs | 21 ++++- .../UI/Layout/CharacterStatControllerTests.cs | 45 +++++++++++ .../AcDream.App.Tests/UI/UiScrollbarTests.cs | 77 +++++++++++++++++++ 3 files changed, 141 insertions(+), 2 deletions(-) diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs index 3ecef835..d6569b3c 100644 --- a/src/AcDream.App/UI/UiScrollbar.cs +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -208,8 +208,21 @@ public sealed class UiScrollbar : UiElement internal bool IsPresentationVisible => !HideWhenDisabled || !IsModelDisabled; + /// + /// A content-fits (disabled) bar is still hit-testable and still + /// hover-highlights — retail's arrows and thumb are real child elements + /// whose Normal_rollover hot-tracking keeps running when the scrollbar + /// disables; the disabled state only hides the page-click regions + /// (UpdateLayout @0x004710d0's children 4-7) and, with attribute 0x79, + /// the whole bar. Scrolling input stays inert through geometry: with a + /// full-track thumb there is no travel and the line/page steps clamp to + /// nothing. The previous IsModelDisabled hit gate made the bar + /// hit-TRANSPARENT, which is why hovering it "did nothing" (2026-08-24 + /// live probe: hovers over the bar reported widget=<none>). + /// Only a presentation-hidden bar (0x79 + disabled) ignores the pointer. + /// protected override bool OnHitTest(float localX, float localY) - => !IsModelDisabled && base.OnHitTest(localX, localY); + => IsPresentationVisible && base.OnHitTest(localX, localY); /// /// Computes the thumb rectangle (local y origin and height) within the track area @@ -520,7 +533,11 @@ public sealed class UiScrollbar : UiElement return false; // informational — never consumes } - if (IsModelDisabled) + // Only a presentation-HIDDEN bar ignores input (see OnHitTest's own + // doc): a visible disabled bar keeps hover/pressed visuals exactly + // like retail's still-hot-tracking button/thumb children, while its + // scroll operations no-op through zero travel. + if (!IsPresentationVisible) { _draggingThumb = false; _hoveredThumb = false; diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 96e142b2..19ba6170 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -1953,6 +1953,51 @@ public class CharacterStatControllerTests Assert.Equal(RetailScrollbarChrome.ThumbMidRollover, scrollbar.ThumbRolloverSprite); } + /// + /// 2026-08-24 owner report: "hovering the scrollbar and arrows does + /// nothing" — root-level hover through the REAL mounted character + /// fixture (production element tree, overlays and z-order included). + /// + [Fact] + public void ProductionFixture_HoveringTheSkillScrollbar_SelectsRolloverMedia() + { + var layout = FixtureLoader.LoadCharacter(); + CharacterStatController.Bind( + layout, + SampleData.SampleCharacter, + spriteResolve: id => (id, 16, 16)); + var root = new UiRoot { Width = 800f, Height = 600f }; + root.AddChild(layout.Root); + ApplyLayoutPass(layout.Root); + + ClickTab(layout, left: 92f); + var page = layout.Root.Children.Single( + e => e.DatElementId == CharacterStatController.AttributesPageId); + var list = Descendants(page).Single( + e => e.DatElementId == CharacterStatController.ListBoxId); + var scrollbar = list.Parent!.Children.OfType().Single( + e => e.DatElementId == CharacterStatController.ListScrollbarId); + Assert.True(scrollbar.Visible); + // The headless harness never measures row content; give the REAL + // bound model an overflowing extent so the bar is enabled the way + // a populated skills list is in production. + Assert.NotNull(scrollbar.Model); + scrollbar.Model!.ContentHeight = 800; + scrollbar.Model.ViewHeight = 398; + Assert.False(scrollbar.IsModelDisabled); + + var screen = scrollbar.ScreenPosition; + // Over the up arrow (5px into the 16px top button). + root.OnMouseMove((int)(screen.X + 8f), (int)(screen.Y + 5f)); + Assert.Equal( + RetailScrollbarChrome.UpRollover, scrollbar.ActiveStartSpriteForTest); + + // Over the thumb (just below the up button; thumb starts at track top). + root.OnMouseMove((int)(screen.X + 8f), (int)(screen.Y + 24f)); + Assert.Equal( + RetailScrollbarChrome.ThumbMidRollover, scrollbar.ActiveThumbSpriteForTest); + } + // ── Helpers ────────────────────────────────────────────────────────────── private static void ClickTab(ImportedLayout layout, float left) diff --git a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs index daa501a3..f9e51e3f 100644 --- a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs +++ b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs @@ -573,6 +573,83 @@ public class UiScrollbarTests Assert.Equal(1u, bar.ActiveThumbSpriteForTest); } + /// + /// 2026-08-24 live-probe root cause: a content-fits (disabled) bar was + /// hit-TRANSPARENT, so hovering it reported widget=<none> and no + /// state ever highlighted. Retail's arrows/thumb are real child + /// elements that keep hot-tracking while the scrollbar is disabled — + /// only the page regions (and, with 0x79, the whole bar) go away. + /// + [Fact] + public void DisabledVisibleBar_StillHoverHighlights_ButNeverScrolls() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + var model = new UiScrollable { ContentHeight = 100, ViewHeight = 150, LineHeight = 10 }; + var bar = new UiScrollbar + { + Left = 100, Top = 100, Width = 16f, Height = 200f, + Model = model, + UpSprite = 1u, UpRolloverSprite = 2u, + ThumbSprite = 4u, ThumbRolloverSprite = 5u, + }; + root.AddChild(bar); + Assert.True(bar.IsModelDisabled); + Assert.True(bar.IsPresentationVisible); + + // Hover the up arrow: highlight, exactly like retail's full-bar state. + root.OnMouseMove(108, 105); + Assert.Equal(2u, bar.ActiveStartSpriteForTest); + + // Hover the (full-track) thumb: highlight. + root.OnMouseMove(108, 130); + Assert.Equal(5u, bar.ActiveThumbSpriteForTest); + + // Clicking consumes (the bar is opaque UI) but cannot scroll. + Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 8, Data2: 5))); + Assert.Equal(0, model.ScrollY); + + // A presentation-HIDDEN bar (0x79 + disabled) stays inert. + bar.HideWhenDisabled = true; + Assert.False(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 8, Data2: 5))); + } + + /// + /// Root-level repro for the 2026-08-24 owner report "hovering the + /// scrollbar and arrows does nothing": drives real + /// hit-test/hover dispatch instead of + /// synthetic direct OnEvent calls. + /// + [Fact] + public void RootMouseMove_OverArrowAndThumb_SelectsRolloverMedia() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + var model = new UiScrollable { ContentHeight = 400, ViewHeight = 150, LineHeight = 10 }; + var bar = new UiScrollbar + { + Left = 100, Top = 100, Width = 16f, Height = 200f, + Model = model, + UpSprite = 1u, UpRolloverSprite = 2u, UpPressedSprite = 3u, + DownSprite = 7u, DownRolloverSprite = 8u, + ThumbSprite = 4u, ThumbRolloverSprite = 5u, + }; + root.AddChild(bar); + + // Over the up arrow (local y = 5, inside the 16px button). + root.OnMouseMove(108, 105); + Assert.Equal(2u, bar.ActiveStartSpriteForTest); + + // Over the thumb (track 16..184, ratio 150/400=0.375 → thumb 16..79 + // local, 116..179 screen). + root.OnMouseMove(108, 130); + Assert.Equal(1u, bar.ActiveStartSpriteForTest); + Assert.Equal(5u, bar.ActiveThumbSpriteForTest); + + // Over the down arrow (local y >= 184). + root.OnMouseMove(108, 290); + Assert.Equal(4u, bar.ActiveThumbSpriteForTest); + Assert.Equal(8u, bar.ActiveEndSpriteForTest); + } + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource { public IGpuFrame? CurrentFrame => null; From c12a95b6e85847846452542219877f82c9769c2e Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 20:27:04 +0200 Subject: [PATCH 28/89] =?UTF-8?q?fix(ui):=20chat=20window=20retail=20parit?= =?UTF-8?q?y=20=E2=80=94=20focus=20rails,=20authored=20captions,=20menu=20?= =?UTF-8?q?flick,=20drag-stable=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four of the five owner-reported chat deltas (2026-08-24), each traced to its retail mechanism: 1. Missing gold separator left of the input: the chat input authors two 1px Type-3 rail CHILDREN (0x10000017 at X=0, 0x10000018 right-anchored) whose only media is Normal_focussed (0x06004D67, live-DAT probed). UiField consumes its DAT children, so the rails were swallowed and never drawn. The factory now folds them into the field, which draws both while focused. 2. Button says "General", retail says "Gen": the talk button's short caption comes from per-target ID_Chat_ChatTargetMenu* strings (HandleSelection @0x004cd540, StringTable 0x23000001 via compute_str_hash — recovered from the raw binary after BN elided the ids into name-hash globals). Authored set: Chat/Tell/Fell/Pat/Mon/ Vas/Alg/Gen/Trade/LFG/RP/Soc/Olt. Menu rows + squelch/tell specials resolve from the same table (ID_Chat_TellTo*); production resolves through DatStringResolver, fallbacks ARE the authored EoR English. ChatStringsLiveDatTests pins the whole set against the installed DAT. 3. Channel button stayed green while the popup was open: retail's pressed face is the momentary physical press ("flicks"); the OPEN state drives only the arrow-cap child's StateDesc swap (UIElement_Menu::UpdateState @0x0046cad0 writes attribute 0xe). UiMenu now keys the face on the press, not on IsOpen. 4. Window-title/button text "vibrates" while dragging windows: DrawStringDatPass snapped glyphs with MathF.Round — banker's rounding. A centered label with a constant .5 fraction alternates round-up/round-down across successive integers, double-stepping then sticking while the background glides. Half-up Floor(v+0.5) snaps every tie one way: uniform 1px steps in lock-step with sprites. The fifth report (input row sticking out on window resize) did not reproduce: a controller-bound fixture resize at 220/300/600px keeps the whole input row inside the window (test added) — awaiting the owner's exact gesture. Co-Authored-By: Claude Fable 5 --- .../UI/Layout/ChatWindowController.cs | 104 +++++++++++------- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 26 +++++ src/AcDream.App/UI/RetailUiRuntime.cs | 6 +- src/AcDream.App/UI/UiField.cs | 36 ++++++ src/AcDream.App/UI/UiMenu.cs | 29 ++++- src/AcDream.App/UI/UiRenderContext.cs | 14 ++- .../UI/Layout/ChatLayoutConformanceTests.cs | 62 +++++++++++ .../UI/Layout/ChatStringsLiveDatTests.cs | 59 ++++++++++ .../UI/Layout/ChatWindowControllerTests.cs | 42 ++++++- .../UI/Layout/DatWidgetFactoryTests.cs | 35 ++++++ tests/AcDream.App.Tests/UI/UiMenuTests.cs | 34 ++++++ 11 files changed, 401 insertions(+), 46 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/Layout/ChatStringsLiveDatTests.cs diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index fb224e99..0b9c3e89 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -188,40 +188,62 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta private string? _tellTarget; - private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems = + /// + /// Authored chat-string lookup (key name in StringTable 0x23000001 — + /// gmMainChatUI resolves every talk-focus label through + /// compute_str_hash'd ID_Chat_* keys; live-DAT probed 2026-08-24). + /// Null (tests / standalone mounts) falls back to the authored EoR + /// English transcribed below. + /// + private Func? _chatStrings; + + private string S(string key, string authoredFallback) + => _chatStrings?.Invoke(key) ?? authoredFallback; + + /// Channel rows: authored ID_Chat_TellTo* key + the authored + /// EoR English as the no-resolver fallback (both live-DAT probed + /// 2026-08-24 — the fallbacks ARE the authored strings). + private static readonly (string Key, string Fallback, ChatChannelKind Channel)[] ChannelItems = { - ("Squelch (ignore)", null), - ("Tell to Selected", null), - ("Chat to All", ChatChannelKind.Say), - ("Tell to Fellows", ChatChannelKind.Fellowship), - ("Tell to General Chat", ChatChannelKind.General), - ("Tell to LFG Chat", ChatChannelKind.Lfg), - ("Tell to Society Chat", ChatChannelKind.Society), - ("Tell to Monarch", ChatChannelKind.Monarch), - ("Tell to Patron", ChatChannelKind.Patron), - ("Tell to Vassals", ChatChannelKind.Vassals), - ("Tell to Allegiance", ChatChannelKind.Allegiance), - ("Tell to Trade Chat", ChatChannelKind.Trade), - ("Tell to Roleplay Chat", ChatChannelKind.Roleplay), - ("Tell to Olthoi Chat", ChatChannelKind.Olthoi), + ("ID_Chat_TellToAll", "Chat to All", ChatChannelKind.Say), + ("ID_Chat_TellToFellows", "Tell to Fellows", ChatChannelKind.Fellowship), + ("ID_Chat_TellToGeneral", "Tell to General Chat", ChatChannelKind.General), + ("ID_Chat_TellToLFG", "Tell to LFG Chat", ChatChannelKind.Lfg), + ("ID_Chat_TellToSociety", "Tell to Society Chat", ChatChannelKind.Society), + ("ID_Chat_TellToMonarch", "Tell to Monarch", ChatChannelKind.Monarch), + ("ID_Chat_TellToPatron", "Tell to Patron", ChatChannelKind.Patron), + ("ID_Chat_TellToVassals", "Tell to Vassals", ChatChannelKind.Vassals), + ("ID_Chat_TellToAllegiance", "Tell to Allegiance", ChatChannelKind.Allegiance), + ("ID_Chat_TellToTrade", "Tell to Trade Chat", ChatChannelKind.Trade), + ("ID_Chat_TellToRoleplay", "Tell to Roleplay Chat", ChatChannelKind.Roleplay), + ("ID_Chat_TellToOlthoi", "Tell to Olthoi Chat", ChatChannelKind.Olthoi), }; - private static string ChannelButtonLabel(ChatChannelKind k) => k switch + /// + /// The talk button's SHORT caption: gmMainChatUI::HandleSelection + /// @0x004cd540 sets m_pChatTargetButtonText from the per-target + /// ID_Chat_ChatTargetMenu* string (table 0x23000001) — authored values + /// 'Chat'/'Tell'/'Fell'/'Pat'/'Mon'/'Vas'/'Alg'/'Gen'/'Trade'/'LFG'/ + /// 'RP'/'Soc'/'Olt' (live-DAT probed 2026-08-24; the previous + /// hand-invented longs like "General"/"Fellow"/"Alleg" were the + /// owner-reported "says General not Gen" delta). + /// + private string ChannelButtonLabel(ChatChannelKind k) => k switch { - ChatChannelKind.Say => "Chat", - ChatChannelKind.Tell => "Tell", - ChatChannelKind.General => "General", - ChatChannelKind.Trade => "Trade", - ChatChannelKind.Lfg => "LFG", - ChatChannelKind.Fellowship => "Fellow", - ChatChannelKind.Allegiance => "Alleg", - ChatChannelKind.Patron => "Patron", - ChatChannelKind.Vassals => "Vassals", - ChatChannelKind.Monarch => "Monarch", - ChatChannelKind.Roleplay => "Roleplay", - ChatChannelKind.Society => "Society", - ChatChannelKind.Olthoi => "Olthoi", - _ => "Chat", + ChatChannelKind.Say => S("ID_Chat_ChatTargetMenu", "Chat"), + ChatChannelKind.Tell => S("ID_Chat_ChatTargetMenuSelected", "Tell"), + ChatChannelKind.General => S("ID_Chat_ChatTargetMenuGeneral", "Gen"), + ChatChannelKind.Trade => S("ID_Chat_ChatTargetMenuTrade", "Trade"), + ChatChannelKind.Lfg => S("ID_Chat_ChatTargetMenuLFG", "LFG"), + ChatChannelKind.Fellowship => S("ID_Chat_ChatTargetMenuFellows", "Fell"), + ChatChannelKind.Allegiance => S("ID_Chat_ChatTargetMenuAllegiance", "Alg"), + ChatChannelKind.Patron => S("ID_Chat_ChatTargetMenuPatron", "Pat"), + ChatChannelKind.Vassals => S("ID_Chat_ChatTargetMenuVassals", "Vas"), + ChatChannelKind.Monarch => S("ID_Chat_ChatTargetMenuMonarch", "Mon"), + ChatChannelKind.Roleplay => S("ID_Chat_ChatTargetMenuRoleplay", "RP"), + ChatChannelKind.Society => S("ID_Chat_ChatTargetMenuSociety", "Soc"), + ChatChannelKind.Olthoi => S("ID_Chat_ChatTargetMenuOlthoi", "Olt"), + _ => S("ID_Chat_ChatTargetMenu", "Chat"), }; private static bool ChannelAvailable(ChatChannelKind k) @@ -278,7 +300,8 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta UiDatFont? datFont, BitmapFont? debugFont, Func resolve, - Func? selectedTargetName = null) + Func? selectedTargetName = null, + Func? chatStrings = null) { ArgumentNullException.ThrowIfNull(windowFilters); @@ -307,6 +330,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta Root = window, DatWindowInfo = FindInfo(rootInfo, RootId) ?? rootInfo, _windowFilters = windowFilters, + _chatStrings = chatStrings, }; // Seed the unlocked skin until the common registered-window presenter @@ -467,19 +491,17 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta var items = new List(ChannelItems.Length) { new(target is null - ? "Squelch (ignore)" - : $"Squelch (ignore) {target}", + ? c.S("ID_Chat_SquelchSelectedNoSelection", + "Squelch (ignore) Selected") + : c.S("ID_Chat_SquelchSelected", "Squelch (ignore) ") + target, TalkFocusSpecial.Squelch), new(target is null - ? "Tell to Selected" - : $"Tell to {target}", + ? c.S("ID_Chat_TellToSelectedNoSelection", "Tell to Selected") + : c.S("ID_Chat_TellToSelected", "Tell to ") + target, TalkFocusSpecial.TellToSelected), }; - foreach ((string label, ChatChannelKind? channel) in ChannelItems) - { - if (channel is { } ch) - items.Add(new UiMenu.MenuItem(label, ch)); - } + foreach ((string key, string fallback, ChatChannelKind ch) in ChannelItems) + items.Add(new UiMenu.MenuItem(c.S(key, fallback), ch)); menu.Items = items.ToArray(); } @@ -497,7 +519,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta }; // The button names the FOCUS, not the target: retail shows "Tell", // not the person's name. - menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel); + menu.ButtonLabelProvider = () => c.ChannelButtonLabel(c._activeChannel); menu.OnOpen = RebuildItems; menu.OnSelect = p => { diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 2e5f7a0b..c81113b5 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -822,6 +822,32 @@ public static class DatWidgetFactory field.TextColor = info.FontColor.Value; if (info.OutlineColor.HasValue) field.OutlineColor = info.OutlineColor.Value; + + // The chat input's authored focus rails (children 0x10000017/18, + // Type 3, media only for Normal_focussed — live-DAT probed + // 2026-08-24): UiField consumes its DAT children, so fold the + // rails into the field and let it draw them while focused (the + // owner-reported missing gold separator next to the channel + // button). Left/right assignment follows the authored X within + // the field, matching each rail's own edge anchoring. + foreach (ElementInfo child in info.Children) + { + if (child.Type != 3u + || !child.StateMedia.TryGetValue("Normal_focussed", out var railMedia) + || railMedia.File == 0u) + continue; + bool leftAnchored = child.X < info.Width * 0.5f; + if (leftAnchored) + { + field.FocusRailLeftSprite = railMedia.File; + if (child.Width > 0f) field.FocusRailLeftWidth = child.Width; + } + else + { + field.FocusRailRightSprite = railMedia.File; + if (child.Width > 0f) field.FocusRailRightWidth = child.Width; + } + } return field; } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index c259f2c3..fa3a1e8d 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -1595,7 +1595,11 @@ public sealed class RetailUiRuntime : IDisposable return null; string? name = _bindings.Toolbar.ResolveName(selected); return string.IsNullOrWhiteSpace(name) ? null : name; - }); + }, + // Authored talk-focus labels (StringTable 0x23000001, ID_Chat_* + // keys via compute_str_hash — the retail "Gen"/"Tell to X" set). + chatStrings: key => new DatStringResolver(_bindings.Assets.Dats) + .Resolve(0x23000001u, DatStringResolver.ComputeHash(key))); if (controller is null) { Console.WriteLine("[D.2b] chat: required role elements missing in 0x2100006F."); diff --git a/src/AcDream.App/UI/UiField.cs b/src/AcDream.App/UI/UiField.cs index 60636d11..b9686104 100644 --- a/src/AcDream.App/UI/UiField.cs +++ b/src/AcDream.App/UI/UiField.cs @@ -87,6 +87,22 @@ public sealed class UiField : UiElement public Func? SpriteResolve { get; set; } /// Unfocused/default state sprite imported from the DAT. public uint BackgroundSprite { get; set; } + /// + /// The 1px vertical end-cap rails the chat input authors as consumed + /// Type-3 CHILDREN (0x10000017 left-anchored at X=0, 0x10000018 + /// right-anchored — both media 0x06004D67, authored ONLY for the + /// Normal_focussed state, live-DAT probed 2026-08-24). Retail lights + /// them with the field: the left one is the gold separator between the + /// channel button and the text (the owner-reported missing bar). + /// is true for fields, so + /// the factory folds the rails into these properties and the field + /// draws them itself while focused. Zero ids draw nothing. + /// + public uint FocusRailLeftSprite { get; set; } + public float FocusRailLeftWidth { get; set; } = 1f; + public uint FocusRailRightSprite { get; set; } + public float FocusRailRightWidth { get; set; } = 1f; + /// Gold "lit" field background drawn when focused (retail Normal_focussed /// state, RenderSurface 0x060011AB). 0 = no focus sprite. public uint FocusFieldSprite { get; set; } @@ -419,6 +435,26 @@ public sealed class UiField : UiElement if (tex != 0 && tw > 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); else lit = false; } + if (_focused && SpriteResolve is not null) + { + // The authored focus rails (see FocusRailLeftSprite's doc): 1px + // vertical gold end caps, lit only while focused, exactly the + // rails' own Normal_focussed authoring. + if (FocusRailLeftSprite != 0) + { + var (tex, tw, _) = SpriteResolve(FocusRailLeftSprite); + if (tex != 0 && tw > 0) + ctx.DrawSprite(tex, 0, 0, FocusRailLeftWidth, Height, 0f, 0f, 1f, 1f, Vector4.One); + } + if (FocusRailRightSprite != 0) + { + var (tex, tw, _) = SpriteResolve(FocusRailRightSprite); + if (tex != 0 && tw > 0) + ctx.DrawSprite( + tex, Width - FocusRailRightWidth, 0, + FocusRailRightWidth, Height, 0f, 0f, 1f, 1f, Vector4.One); + } + } if (!lit && SpriteResolve is not null && BackgroundSprite != 0) { var (tex, tw, th) = SpriteResolve(BackgroundSprite); diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index 5cff6a3c..cf76ecd6 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -196,6 +196,11 @@ public sealed class UiMenu : UiElement /// reason). public uint CurrentArrowCapSprite => _open ? ArrowCapOpenSprite : ArrowCapClosedSprite; + /// The face sprite would pick right now — + /// keyed on the momentary physical press (_facePressed), NOT on + /// open state (see _facePressed's doc). Test seam. + public uint CurrentFaceSpriteForTest => _facePressed ? PressedSprite : NormalSprite; + public UiDatFont? DatFont { get; set; } public AcDream.App.Rendering.BitmapFont? Font { get; set; } @@ -272,6 +277,19 @@ public sealed class UiMenu : UiElement private bool _open; + /// + /// True only while the pointer is physically pressed on the button FACE. + /// 2026-08-24 owner report: the chat channel button stayed green (pressed + /// art) the whole time the popup was open — retail's pressed face + /// (0x06004D66) is the ordinary momentary button press ("flicks green"), + /// while the OPEN state drives only the arrow-cap child's state swap + /// (UIElement_Menu::UpdateState @0x0046cad0 writes attribute 0xe, which + /// the cap element's own StateDesc consumes — see + /// 's doc). The face must key on the + /// physical press, not on . + /// + private bool _facePressed; + /// Whether the popup is currently open (test/inspection seam, /// same rationale as /). public bool IsOpen => _open; @@ -374,7 +392,7 @@ public sealed class UiMenu : UiElement // Button face (3-sliced so it can widen to fit the label) + the active-target label. if (resolve is not null) { - var (tex, tw, _) = resolve(_open ? PressedSprite : NormalSprite); + var (tex, tw, _) = resolve(_facePressed ? PressedSprite : NormalSprite); if (tex != 0 && tw > 0) DrawButtonFace(ctx, tex, tw); } string caption = ButtonLabelProvider?.Invoke() ?? ""; @@ -660,6 +678,14 @@ public sealed class UiMenu : UiElement } } + if (e.Type is UiEventType.MouseUp + or UiEventType.HoverLeave + or UiEventType.CaptureChanged) + { + _facePressed = false; // the momentary face flick ends here + return false; + } + if (e.Type != UiEventType.MouseDown) return false; float lx = e.Data1, ly = e.Data2; @@ -700,6 +726,7 @@ public sealed class UiMenu : UiElement // Retail Open @0x0046cc42 refuses an empty list (gates on // m_listBox->m_listItems.m_num != 0) — a bare click on an itemless // menu is a no-op rather than an empty popup. + _facePressed = true; // momentary press flick if (!_open && Items.Count == 0) return true; SetOpen(!_open); // toggle on button click return true; diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs index d4ed5b11..6abfbf83 100644 --- a/src/AcDream.App/UI/UiRenderContext.cs +++ b/src/AcDream.App/UI/UiRenderContext.cs @@ -338,7 +338,16 @@ public sealed class UiRenderContext // digits never showed it because their bar baseline lands on an integer; chat text // does. Snapping the baseline once, then adding the integer offset, keeps the whole // line on one row and pixel-aligned. - float baseY = System.MathF.Round(originY); + // + // HALF-UP, not MathF.Round (2026-08-24 owner report: window-title and + // button labels "vibrate" while dragging a window): MathF.Round is + // banker's rounding — a centered label whose origin carries a constant + // .5 fraction (odd text width over /2) alternates round-up/round-down + // as the dragged window crosses successive integers, so the text + // double-steps then sticks while the background glides 1px per frame. + // Floor(v + 0.5) snaps every tie the same direction: constant fraction + // → uniform 1px steps in lock-step with the sprites. + float baseY = System.MathF.Floor(originY + 0.5f); float pen = originX; for (int i = 0; i < text.Length; i++) @@ -349,7 +358,8 @@ public sealed class UiRenderContext // Horizontal: snap each glyph's dest X to a whole pixel (the pen keeps its // true fractional advance). Vertical: integer baseline + integer per-glyph // offset — never an independent per-glyph round (see baseY's note above). - float gx = System.MathF.Round(pen + g.HorizontalOffsetBefore); + // Half-up for the same anti-vibration reason as baseY. + float gx = System.MathF.Floor(pen + g.HorizontalOffsetBefore + 0.5f); float gy = baseY + g.VerticalOffsetBefore; float gw = g.Width; float gh = g.Height; diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs index a32066da..122e3b38 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs @@ -672,4 +672,66 @@ public class ChatLayoutConformanceTests scroll.SetExtents(contentHeight: 400, viewHeight: 50, preserveEnd: true); Assert.Equal(draggedPosition, scroll.ScrollY); } + + /// + /// 2026-08-24 owner report: resizing the chat window let the text input + /// stick out past the window edge. The authored edge modes (input row + /// 0x10000013 L1/R1 = stretch, field 0x10000016 L1/R1 = stretch, Send + /// 0x10000019 L2/R1 = right-docked, menu button 0x10000014 L1/R2 = + /// left-docked) must keep the whole input row inside the window at every + /// size, narrower AND wider than the authored 410. + /// + [Theory] + [InlineData(300f, 100f)] + [InlineData(600f, 160f)] + [InlineData(220f, 80f)] + public void ResizingTheWindow_KeepsTheInputRowInsideIt(float width, float height) + { + var infos = FixtureLoader.LoadChatInfos(); + ImportedLayout layout = LayoutImporter.Build(infos, NoTex, null); + // Bind the REAL controller — production geometry overrides included. + var controller = ChatWindowController.Bind( + infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, + new ChatWindowState(), null, null, NoTex); + Assert.NotNull(controller); + UiElement window = layout.FindElement(0x10000600u)!; + var root = new UiRoot { Width = 800f, Height = 600f }; + root.AddChild(window); + ApplyLayoutPassLocal(window); + + window.Width = width; + window.Height = height; + window.ResetAnchorCapture(); + ApplyLayoutPassLocal(window); + ApplyLayoutPassLocal(window); // second frame — policies settle + + UiElement inputBar = layout.FindElement(0x10000013u)!; + UiElement input = layout.FindElement(0x10000016u)!; + UiElement send = layout.FindElement(0x10000019u)!; + UiElement menuButton = layout.FindElement(0x10000014u)!; + + Assert.True(inputBar.Left >= 0f && inputBar.Left + inputBar.Width <= width + 0.5f, + $"input bar [{inputBar.Left},{inputBar.Left + inputBar.Width}] escapes window width {width}"); + float inputRight = inputBar.Left + input.Left + input.Width; + Assert.True(inputRight <= width + 0.5f, + $"input field right {inputRight} escapes window width {width}"); + float sendRight = inputBar.Left + send.Left + send.Width; + Assert.True(sendRight <= width + 0.5f, + $"send right {sendRight} escapes window width {width}"); + Assert.True(menuButton.Left >= 0f, "menu button escaped left"); + // The field must stay BETWEEN the menu button and the send button. + Assert.True(input.Left >= menuButton.Left + menuButton.Width - 0.5f, + $"input {input.Left} overlaps menu button ending {menuButton.Left + menuButton.Width}"); + Assert.True(input.Left + input.Width <= send.Left + 0.5f, + $"input ends {input.Left + input.Width} past send start {send.Left}"); + } + + private static void ApplyLayoutPassLocal(UiElement parent) + { + foreach (var child in parent.Children) + { + child.ApplyAnchor(parent.Width, parent.Height); + ApplyLayoutPassLocal(child); + } + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatStringsLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatStringsLiveDatTests.cs new file mode 100644 index 00000000..06218fce --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ChatStringsLiveDatTests.cs @@ -0,0 +1,59 @@ +using AcDream.App.UI.Layout; +using DatReaderWriter; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// 2026-08-24 pin: the talk-focus labels resolve from StringTable +/// 0x23000001 via compute_str_hash'd ID_Chat_* keys — +/// the mechanism gmMainChatUI::HandleSelection @0x004cd540 (button +/// shorts) and InitTalkFocusMenu @0x004cdc50 (menu rows) use. The +/// authored shorts are ABBREVIATIONS ('Gen', not "General" — the +/// owner-reported delta); guard both families so a DAT revision or resolver +/// regression fails loudly instead of silently reverting to fallbacks. +/// +[Trait("Lane", "InstalledDat")] +public sealed class ChatStringsLiveDatTests +{ + private static string DatDirectory => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + + [InstalledDatFact] + public void TalkFocusStrings_ResolveToTheAuthoredRetailSet() + { + using var dats = new DatCollection( + DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + var strings = new DatStringResolver(dats); + const uint table = 0x23000001u; + + string? Resolve(string key) + => strings.Resolve(table, DatStringResolver.ComputeHash(key)); + + // Button shorts (m_pChatTargetButtonText). + Assert.Equal("Chat", Resolve("ID_Chat_ChatTargetMenu")); + Assert.Equal("Tell", Resolve("ID_Chat_ChatTargetMenuSelected")); + Assert.Equal("Fell", Resolve("ID_Chat_ChatTargetMenuFellows")); + Assert.Equal("Pat", Resolve("ID_Chat_ChatTargetMenuPatron")); + Assert.Equal("Mon", Resolve("ID_Chat_ChatTargetMenuMonarch")); + Assert.Equal("Vas", Resolve("ID_Chat_ChatTargetMenuVassals")); + Assert.Equal("Alg", Resolve("ID_Chat_ChatTargetMenuAllegiance")); + Assert.Equal("Gen", Resolve("ID_Chat_ChatTargetMenuGeneral")); + Assert.Equal("Trade", Resolve("ID_Chat_ChatTargetMenuTrade")); + Assert.Equal("LFG", Resolve("ID_Chat_ChatTargetMenuLFG")); + Assert.Equal("RP", Resolve("ID_Chat_ChatTargetMenuRoleplay")); + Assert.Equal("Soc", Resolve("ID_Chat_ChatTargetMenuSociety")); + Assert.Equal("Olt", Resolve("ID_Chat_ChatTargetMenuOlthoi")); + + // Menu rows + specials composition sources. + Assert.Equal("Chat to All", Resolve("ID_Chat_TellToAll")); + Assert.Equal("Tell to General Chat", Resolve("ID_Chat_TellToGeneral")); + Assert.Equal("Tell to ", Resolve("ID_Chat_TellToSelected")); + Assert.Equal("Tell to Selected", Resolve("ID_Chat_TellToSelectedNoSelection")); + Assert.Equal("Squelch (ignore) ", Resolve("ID_Chat_SquelchSelected")); + Assert.Equal( + "Squelch (ignore) Selected", + Resolve("ID_Chat_SquelchSelectedNoSelection")); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs index 93b53416..2ef6ef58 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs @@ -374,7 +374,9 @@ public class ChatWindowControllerTests UiMenu menu = Assert.IsType(layout.FindElement(0x10000014u)); menu.OnOpen!.Invoke(); - Assert.Equal("Squelch (ignore)", menu.Items[0].Label); + // Authored no-selection labels (live-DAT probed 2026-08-24: + // ID_Chat_SquelchSelectedNoSelection / ID_Chat_TellToSelectedNoSelection). + Assert.Equal("Squelch (ignore) Selected", menu.Items[0].Label); Assert.Equal("Tell to Selected", menu.Items[1].Label); // Retail arms the tell slot only once a talkable object is selected @@ -738,4 +740,42 @@ public class ChatWindowControllerTests Assert.Throws(() => ctrl.SetIndicatorOpen(windowId, open: true)); } + + /// + /// 2026-08-24 owner report ("says General not Gen"): the talk button's + /// SHORT caption comes from the per-target ID_Chat_ChatTargetMenu* + /// strings (gmMainChatUI::HandleSelection @0x004cd540, table + /// 0x23000001) — authored 'Gen', not the invented "General". The + /// no-resolver fallbacks ARE the authored EoR strings; a resolver + /// (production) overrides them for localization. + /// + [Fact] + public void TalkButton_UsesAuthoredShortCaptions() + { + var (rootInfo, layout, vm) = BuildTestTree(); + ChatWindowController? ctrl = ChatWindowController.Bind( + rootInfo, layout, vm, () => NullCommandBus.Instance, + new ChatWindowState(), null, null, NoTex); + Assert.NotNull(ctrl); + UiMenu menu = Assert.IsType(layout.FindElement(0x10000014u)); + + Assert.Equal("Chat", menu.ButtonLabelProvider!()); + menu.OnSelect!.Invoke(ChatChannelKind.General); + Assert.Equal("Gen", menu.ButtonLabelProvider()); + menu.OnSelect.Invoke(ChatChannelKind.Lfg); + Assert.Equal("LFG", menu.ButtonLabelProvider()); + menu.OnSelect.Invoke(ChatChannelKind.Trade); + Assert.Equal("Trade", menu.ButtonLabelProvider()); + + // A DAT resolver (production) wins over the fallback. + var (rootInfo2, layout2, vm2) = BuildTestTree(); + ChatWindowController? ctrl2 = ChatWindowController.Bind( + rootInfo2, layout2, vm2, () => NullCommandBus.Instance, + new ChatWindowState(), null, null, NoTex, + chatStrings: key => key == "ID_Chat_ChatTargetMenuGeneral" ? "LOC" : null); + Assert.NotNull(ctrl2); + UiMenu menu2 = Assert.IsType(layout2.FindElement(0x10000014u)); + menu2.OnSelect!.Invoke(ChatChannelKind.General); + Assert.Equal("LOC", menu2.ButtonLabelProvider!()); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 7644d6af..467f8155 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -181,6 +181,41 @@ public class DatWidgetFactoryTests Assert.Equal(80, field.MaxCharacters); } + /// + /// 2026-08-24 owner report (missing gold separator left of the chat + /// input): the chat input authors two 1px Type-3 rail CHILDREN + /// (0x10000017 at X=0, 0x10000018 right-anchored) whose only media is + /// Normal_focussed (0x06004D67, live-DAT probed). UiField consumes its + /// DAT children, so the factory must fold the rails into the field for + /// its own focused draw. + /// + [Fact] + public void Type12_EditableField_FoldsAuthoredFocusRailsIntoTheWidget() + { + var info = TextInfo((0x16u, Bool(true))); + info.Width = 306f; + info.Height = 17f; + var leftRail = new ElementInfo + { + Id = 0x10000017u, Type = 3u, X = 0f, Width = 1f, Height = 17f, + }; + leftRail.StateMedia["Normal_focussed"] = (0x06004D67u, 1); + var rightRail = new ElementInfo + { + Id = 0x10000018u, Type = 3u, X = 305f, Width = 1f, Height = 17f, + }; + rightRail.StateMedia["Normal_focussed"] = (0x06004D67u, 1); + info.Children.Add(leftRail); + info.Children.Add(rightRail); + + var field = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); + + Assert.Equal(0x06004D67u, field.FocusRailLeftSprite); + Assert.Equal(1f, field.FocusRailLeftWidth); + Assert.Equal(0x06004D67u, field.FocusRailRightSprite); + Assert.Equal(1f, field.FocusRailRightWidth); + } + [Fact] public void Type12_SelectableProperty_MakesSelectableUiText() { diff --git a/tests/AcDream.App.Tests/UI/UiMenuTests.cs b/tests/AcDream.App.Tests/UI/UiMenuTests.cs index 2127ada4..d5c2cd55 100644 --- a/tests/AcDream.App.Tests/UI/UiMenuTests.cs +++ b/tests/AcDream.App.Tests/UI/UiMenuTests.cs @@ -561,4 +561,38 @@ public class UiMenuTests Assert.False(menu.ItemTextCentered); Assert.False(menu.PopupSizeToContent); } + + /// + /// 2026-08-24 owner report: the chat channel button stayed green + /// (pressed art) the whole time the popup was open — retail's pressed + /// face is the momentary physical press ("flicks green"); the OPEN + /// state drives only the arrow-cap child (UIElement_Menu::UpdateState + /// @0x0046cad0 writes attribute 0xe for the cap's own StateDesc). + /// + [Fact] + public void ButtonFace_FlicksPressedOnClick_NotLatchedWhileOpen() + { + UiMenu menu = MakeMenu(); + menu.NormalSprite = 10u; + menu.PressedSprite = 20u; + + Assert.Equal(10u, menu.CurrentFaceSpriteForTest); + + // Press on the face: pressed art while the button is held. + menu.OnEvent(new UiEvent(0u, menu, UiEventType.MouseDown, Data1: 5, Data2: 5)); + Assert.True(menu.IsOpen); + Assert.Equal(20u, menu.CurrentFaceSpriteForTest); + + // Release: the flick ends — face returns to normal WHILE open. + menu.OnEvent(new UiEvent(0u, menu, UiEventType.MouseUp, Data1: 5, Data2: 5)); + Assert.True(menu.IsOpen); + Assert.Equal(10u, menu.CurrentFaceSpriteForTest); + + // Second face press closes and flicks again; release ends the flick. + menu.OnEvent(new UiEvent(0u, menu, UiEventType.MouseDown, Data1: 5, Data2: 5)); + Assert.False(menu.IsOpen); + Assert.Equal(20u, menu.CurrentFaceSpriteForTest); + menu.OnEvent(new UiEvent(0u, menu, UiEventType.MouseUp, Data1: 5, Data2: 5)); + Assert.Equal(10u, menu.CurrentFaceSpriteForTest); + } } From 67e963524bf1fa5168caab36cf3b5b4ac521c526 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 20:43:46 +0200 Subject: [PATCH 29/89] fix(ui): talk button keeps authored 46x17; white authored captions + font for talk/Send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner report (2026-08-24, post gate-pass): the chat channel button was bigger than retail and both its caption and the Send caption were warm gold instead of retail's near-white. Size: retail never resizes the talk button — HandleSelection @0x004cd540 only swaps the caption string; the authored 46x17 element stands, and the authored SHORT captions ('Gen', 'Fell', ...) fit it — that is why retail abbreviates. Our content-widening reflow (grow the button to its label, shift the input) was a compensation for the now-retired invented long captions, measured with the wrong font on top. Deleted; the authored row layout stands. Color + font: the button caption child (0x10000015) and the Send button (0x10000019) both author pure white text with their OWN FontDid 0x40000002 (live-DAT probed) — different from the transcript font, which is the other half of why 'Chat' fits 46px. UiMenu gains a ButtonDatFont for the caption (popup rows keep the menu font); the controller reads both elements' authored FontColor/FontDid instead of the invented (1,.92,.72) constants. The old widening pin is rewritten to the retail contract; a new conformance test pins authored width, white captions, and the authored font DID being requested for both buttons. Co-Authored-By: Claude Fable 5 --- .../UI/Layout/ChatWindowController.cs | 56 +++++++++++-------- src/AcDream.App/UI/RetailUiRuntime.cs | 3 +- src/AcDream.App/UI/UiMenu.cs | 25 ++++++++- .../UI/Layout/ChatLayoutConformanceTests.cs | 55 ++++++++++++++++-- 4 files changed, 106 insertions(+), 33 deletions(-) diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 0b9c3e89..44012ce1 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -62,6 +62,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta private const uint TrackId = 0x10000012u; private const uint InputBarId = 0x10000013u; private const uint MenuId = 0x10000014u; + private const uint MenuLabelId = 0x10000015u; // button caption child: white, FontDid 0x40000002 private const uint InputId = 0x10000016u; // Type-12 Text + Editable 0x16 → UiField private const uint SendId = 0x10000019u; private const uint MaxMinId = 0x1000046Fu; @@ -301,7 +302,8 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta BitmapFont? debugFont, Func resolve, Func? selectedTargetName = null, - Func? chatStrings = null) + Func? chatStrings = null, + Func? resolveFont = null) { ArgumentNullException.ThrowIfNull(windowFilters); @@ -478,6 +480,19 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta if (layout.FindElement(MenuId) is UiMenu menu) { menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve; + // The authored label child (0x10000015) carries the button + // caption's OWN color and font: pure white, FontDid 0x40000002 + // (live-DAT probed 2026-08-24) — not the transcript font and not + // the invented warm gold. Falls back to the previous defaults + // when the fixture lacks the child (hermetic tests). + if (FindInfo(rootInfo, MenuLabelId) is { } labelInfo) + { + if (labelInfo.FontColor is { } authoredColor) + menu.TextColor = authoredColor; + if (labelInfo.FontDid != 0u + && resolveFont?.Invoke(labelInfo.FontDid) is { } buttonFont) + menu.ButtonDatFont = buttonFont; + } menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed; menu.PopupBgSprite = MenuPopupBg; menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected; @@ -559,33 +574,28 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta if (layout.FindElement(SendId) is UiButton sendEl) { sendEl.OnClick = () => c.Input.Submit(); - // The Send sprite is a blank gold button — retail draws the caption as text. + // The Send sprite is a blank gold button — retail draws the caption + // as text with the element's OWN authored style: pure white, + // FontDid 0x40000002 (live-DAT probed 2026-08-24; the previous + // warm gold was invented). sendEl.Label = "Send"; - sendEl.LabelFont = datFont; - sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f); + ElementInfo? sendInfo = FindInfo(rootInfo, SendId); + sendEl.LabelFont = + (sendInfo?.FontDid is { } sendFontDid and not 0u + ? resolveFont?.Invoke(sendFontDid) + : null) ?? datFont; + sendEl.LabelColor = sendInfo?.FontColor ?? new Vector4(1f, 1f, 1f, 1f); } // ── Size the channel button to its label + reflow the input field ─ // Retail's talk-focus button autosizes to the selected channel name; the input - // field then fills the gap from the button's right edge to the Send button. The - // dat authors the button at a fixed 46px (too narrow for "Chat" once the LED + - // arrow are accounted for), so widen it to its content and shift the input. - // Recompute on every channel change (the button grows/shrinks with the label). - if (c.Menu is not null) - { - float inputRight = c.Input.Left + c.Input.Width; // == Send button's left edge - void ReflowInputRow() - { - c.Menu.Width = System.MathF.Round(c.Menu.NaturalButtonWidth()); - c.Menu.ResetAnchorCapture(); - c.Input.Left = c.Menu.Left + c.Menu.Width; - c.Input.Width = System.MathF.Max(40f, inputRight - c.Input.Left); - c.Input.ResetAnchorCapture(); - } - var onSelect = c.Menu.OnSelect; - c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); }; - ReflowInputRow(); - } + // 2026-08-24: the previous content-widening reflow here (grow the + // button to its label, shift the input) was a compensation for the + // invented LONG captions ("General"). Retail keeps the authored + // 46x17 button — the authored SHORT captions ('Gen', font + // 0x40000002) fit it, which is exactly why retail abbreviates + // (gmMainChatUI::HandleSelection @0x004cd540 never resizes the + // element). The authored row layout stands untouched. // ── Max/min toggle — gmMainChatUI::HandleMaximizeButton ── // The dat already authors max/min (368,5,16,16) just left of the scrollbar diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index fa3a1e8d..e743ce94 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -1599,7 +1599,8 @@ public sealed class RetailUiRuntime : IDisposable // Authored talk-focus labels (StringTable 0x23000001, ID_Chat_* // keys via compute_str_hash — the retail "Gen"/"Tell to X" set). chatStrings: key => new DatStringResolver(_bindings.Assets.Dats) - .Resolve(0x23000001u, DatStringResolver.ComputeHash(key))); + .Resolve(0x23000001u, DatStringResolver.ComputeHash(key)), + resolveFont: _bindings.Assets.ResolveFont); if (controller is null) { Console.WriteLine("[D.2b] chat: required role elements missing in 0x2100006F."); diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index cf76ecd6..fdcf54b9 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -202,6 +202,17 @@ public sealed class UiMenu : UiElement public uint CurrentFaceSpriteForTest => _facePressed ? PressedSprite : NormalSprite; public UiDatFont? DatFont { get; set; } + + /// + /// Optional separate font for the BUTTON caption. The chat talk button's + /// authored label child (0x10000015) carries its own FontDid 0x40000002 — + /// different from the popup rows/transcript font — which is how retail + /// fits 'Chat'/'Gen' inside the authored 46x17 face (live-DAT probed + /// 2026-08-24, the owner-reported "button too big" delta). Null falls + /// back to . + /// + public UiDatFont? ButtonDatFont { get; set; } + public AcDream.App.Rendering.BitmapFont? Font { get; set; } /// Retail LayoutDesc property 0x21 (two-pass glyph outline, @@ -400,10 +411,17 @@ public sealed class UiMenu : UiElement // label child spans the button MINUS the arrow-cap overlay's right // socket (0x10000355 is 100 wide of the 117 button, docked; the // arrow child overlays the last 17px — menuprobe3). + UiDatFont? captionFont = ButtonDatFont ?? DatFont; + float captionW = captionFont?.MeasureWidth(caption) + ?? Font?.MeasureWidth(caption) ?? caption.Length * 7f; + float captionLineH = captionFont?.LineHeight ?? Font?.LineHeight ?? 14f; float capX = ButtonTextCentered - ? MathF.Max(0f, (Width - (ArrowCapClosedSprite != 0 ? ArrowCapWidth : 0f) - MeasureText(caption)) * 0.5f) + ? MathF.Max(0f, (Width - (ArrowCapClosedSprite != 0 ? ArrowCapWidth : 0f) - captionW) * 0.5f) : ButtonTextIndent; - DrawLabel(ctx, caption, capX, (Height - LineH()) * 0.5f, TextColor); + if (captionFont is { } cf) + ctx.DrawStringDat(cf, caption, capX, (Height - captionLineH) * 0.5f, TextColor, Outline, OutlineColor); + else + ctx.DrawString(caption, capX, (Height - captionLineH) * 0.5f, TextColor, Font); // G6: the open/closed arrow-cap overlay — see ArrowCapClosedSprite's doc comment. if (resolve is not null) DrawArrowCap(ctx, resolve); @@ -443,7 +461,8 @@ public sealed class UiMenu : UiElement public float NaturalButtonWidth() { string text = ButtonLabelProvider?.Invoke() ?? ""; - float textW = DatFont?.MeasureWidth(text) ?? Font?.MeasureWidth(text) ?? text.Length * 7f; + UiDatFont? nf = ButtonDatFont ?? DatFont; + float textW = nf?.MeasureWidth(text) ?? Font?.MeasureWidth(text) ?? text.Length * 7f; return ButtonTextIndent + textW + 4f + FaceCapR; // text start (clears LED) + text + gap + arrow cap } diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs index 122e3b38..502087ca 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs @@ -368,8 +368,15 @@ public class ChatLayoutConformanceTests Assert.Equal(605f, inputBar.Width); } + /// + /// 2026-08-24 rewrite: this test used to pin the content-widening + /// reflow (button grown past 46px for the invented long captions). + /// Retail never resizes the talk button — the authored 46x17 stands + /// through selection changes AND layout passes, and the input keeps its + /// authored start (owner-reported "button too big" delta). + /// [Fact] - public void ChatFixture_ChannelCaptionWidth_SurvivesImportedLayoutPass() + public void ChatFixture_ChannelButton_KeepsAuthoredWidthThroughLayoutPass() { var infos = FixtureLoader.LoadChatInfos(); var layout = LayoutImporter.Build(infos, NoTex, null); @@ -384,13 +391,12 @@ public class ChatLayoutConformanceTests NoTex); Assert.NotNull(controller); - controller!.Menu.OnSelect!.Invoke(ChatChannelKind.General); - float fittedWidth = controller.Menu.Width; + float inputLeft = controller!.Input.Left; + controller.Menu.OnSelect!.Invoke(ChatChannelKind.General); controller.Menu.ApplyAnchor(controller.Menu.Parent!.Width, controller.Menu.Parent.Height); - Assert.True(fittedWidth > 46f); - Assert.Equal(fittedWidth, controller.Menu.Width); - Assert.Equal(controller.Menu.Left + fittedWidth, controller.Input.Left); + Assert.Equal(46f, controller.Menu.Width); + Assert.Equal(inputLeft, controller.Input.Left); } [Fact] @@ -734,4 +740,41 @@ public class ChatLayoutConformanceTests ApplyLayoutPassLocal(child); } } + + /// + /// 2026-08-24 owner report ("chat button too big; caption should be + /// whiter — same for Send"): retail keeps the AUTHORED 46x17 talk + /// button (HandleSelection @0x004cd540 never resizes it — the authored + /// SHORT captions fit, which is why retail abbreviates), and both the + /// button caption child (0x10000015) and the Send button (0x10000019) + /// author PURE WHITE text with their own FontDid 0x40000002. The + /// previous content-widening reflow and invented warm-gold labels are + /// retired. + /// + [Fact] + public void TalkButtonAndSend_KeepAuthoredSizeFontAndWhiteCaptions() + { + var infos = FixtureLoader.LoadChatInfos(); + ImportedLayout layout = LayoutImporter.Build(infos, NoTex, null); + var requestedFonts = new List(); + var controller = ChatWindowController.Bind( + infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, + new ChatWindowState(), null, null, NoTex, + resolveFont: did => { requestedFonts.Add(did); return null; }); + Assert.NotNull(controller); + + UiMenu menu = Assert.IsType(layout.FindElement(0x10000014u)); + // Authored 46x17 stands — no content widening. + Assert.Equal(46f, menu.Width); + menu.OnSelect!.Invoke(ChatChannelKind.General); + Assert.Equal(46f, menu.Width); + // Authored caption color: pure white. + Assert.Equal(new System.Numerics.Vector4(1f, 1f, 1f, 1f), menu.TextColor); + + var send = Assert.IsType(layout.FindElement(0x10000019u)); + Assert.Equal(new System.Numerics.Vector4(1f, 1f, 1f, 1f), send.LabelColor); + + // Both caption fonts were requested from the authored FontDid. + Assert.Contains(0x40000002u, requestedFonts); + } } From aa94ddebe3f873accde87036c57b981917a92c62 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 20:47:01 +0200 Subject: [PATCH 30/89] fix(ui): talk-button caption centers per the authored label child MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner screenshot pair (2026-08-24): retail centers 'Chat' in the whole 46x17 face; we drew it at the synthetic 20px left indent. The caption child (0x10000015) spans the full button with H=Center/V=Center and no margins (live-DAT probed) — the controller now derives ButtonTextCentered from the authored HJustify instead of leaving the indent default. Co-Authored-By: Claude Fable 5 --- src/AcDream.App/UI/Layout/ChatWindowController.cs | 5 +++++ .../UI/Layout/ChatLayoutConformanceTests.cs | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index 44012ce1..ff31b824 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -492,6 +492,11 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta if (labelInfo.FontDid != 0u && resolveFont?.Invoke(labelInfo.FontDid) is { } buttonFont) menu.ButtonDatFont = buttonFont; + // The child spans the FULL 46x17 with H=Center (probed) — + // retail centers 'Chat'/'Gen' in the whole face, not at a + // fixed left indent (owner screenshot pair, 2026-08-24). + menu.ButtonTextCentered = + labelInfo.HJustify == HJustify.Center; } menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed; menu.PopupBgSprite = MenuPopupBg; diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs index 502087ca..bb7ff5b6 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs @@ -768,8 +768,10 @@ public class ChatLayoutConformanceTests Assert.Equal(46f, menu.Width); menu.OnSelect!.Invoke(ChatChannelKind.General); Assert.Equal(46f, menu.Width); - // Authored caption color: pure white. + // Authored caption color: pure white; authored H=Center over the + // full 46x17 face (not the synthetic 20px left indent). Assert.Equal(new System.Numerics.Vector4(1f, 1f, 1f, 1f), menu.TextColor); + Assert.True(menu.ButtonTextCentered); var send = Assert.IsType(layout.FindElement(0x10000019u)); Assert.Equal(new System.Numerics.Vector4(1f, 1f, 1f, 1f), send.LabelColor); From 1cd4dfc92aba17840fcee80021c2b46340456a7e Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 21:05:19 +0200 Subject: [PATCH 31/89] =?UTF-8?q?docs:=20Campaign=20CT=20plan=20=E2=80=94?= =?UTF-8?q?=20character-panel=20parity=20(Titles=20page,=20header=20identi?= =?UTF-8?q?ty,=20resize/scrollbar,=20row=20alignment)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner-reported 2026-08-24 batch, recon-verified against gmCharacterTitleUI @0x0049A610 / gmStatManagementUI::PostInit @0x004EFD90 / ACE's CharacterTitle-UpdateTitle-TitleSet wire trio. Retires AP-109 when CT3/CT4 land. Fable plans, Sonnet implements, Opus dual-lens reviews; no push until the owner directs. Co-Authored-By: Claude Fable 5 --- ...6-08-24-character-panel-parity-campaign.md | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 docs/plans/2026-08-24-character-panel-parity-campaign.md diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md new file mode 100644 index 00000000..8de1aad3 --- /dev/null +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -0,0 +1,139 @@ +# Campaign CT — Character-panel retail parity (header identity, Titles page, resize/scrollbar, row alignment) + +**Status:** PLANNED 2026-08-24 (owner gate report after the CA5/chat rounds). +**Execution model:** Fable plans and coordinates; Sonnet implements each +slice; Opus runs the dual-lens review (retail-faithful + architectural) +per slice, then a fix round. No pushes to gitea until the owner says so. +**Register:** this campaign retires AP-109 (inert Titles page) when CT3+CT4 +land; every deviation a slice introduces adds its row in the same commit. + +## Owner report (2026-08-24, screenshots on file) + +1. Attribute/skill row **icons misaligned** vs retail. +2. Retail keeps a **margin between the value column and the border** — + the gutter reserved for the list scrollbar that appears when the + window is resized shorter. We author no such margin and never show + the scrollbar on Attributes. +3. The character window is **resizable in Y down to an authored + minimum** in retail; ours is not. +4. Header identity block: retail shows the name, then + **" "** (e.g. "Female Aluvian War + Mage"), then **"Non-Player Killer" / "Player Killer" / + "Player Killer Lite"** in **pure white** — on Attributes AND Skills. + We show gender+heritage only, no PK line contract, color off. +5. **Level number color** slightly off vs retail. +6. **Titles tab is inert** (AP-109): retail lists all earned titles + (sorted), shows the current display title, and lets the player set + one ("Set as Display Title"); scrollbar with many titles; the + header identity line updates live when the display title changes. +7. **All windows share retail's authored minimum-size behavior** — + resize clamps to the authored constraints everywhere. + +## Retail recon (verified 2026-08-24, this session) + +### Titles page — `gmCharacterTitleUI` +- `PostInit @0x0049A610` binds: display-title text `0x1000052F`, + "Set as Display Title" button `0x10000535`, title ListBox + `0x10000532`. Registers notice handlers for the title-table / + add-title / set-display-title notices. +- Rows carry the title id in attribute `0x1000008E`; + `AddTitleToList @0x0049A840` resolves the display string via + `CharacterTitleTable::GetCharacterTitleFromID` (DAT title-string + table — CT2 locates the DID) and inserts SORTED + (`FindSortedInsertPosition @0x0049A760`). +- `UpdateButtons @0x0049A500`: the display button GHOSTS (state 0xd) + when the selected row's title id == `mDisplayTitle`; Normal (1) + otherwise. Selection change (msg 4/0x43) re-runs it. +- Clicking `0x10000535` sends + `CM_Social::Event_SetDisplayCharacterTitle(titleId)` + (`ListenToElementMessage @0x0049A6D0`). +- `gmStatManagementUI::RecvNotice_SetDisplayCharacterTitle @0x004EFD50` + → the stat panel refreshes its header when the display title changes. + +### Wire (ACE cross-checked) +- Inbound `CharacterTitle` event `0x0029` (already in our + `GameEventType`): `u32 =1, u32 displayTitleId, u32 count, + count × u32 titleId` (`GameEventCharacterTitle.cs`). +- Inbound `UpdateTitle` event `0x002B`: `u32 titleId, + u32 setAsDisplay` (`GameEventUpdateTitle.cs`). +- Outbound `TitleSet` GameAction (`GameActionSetTitle.cs`): + `u32 titleId`. Retail sender: `CM_Social::Event_SetDisplayCharacterTitle`. + +### Header identity — `gmStatManagementUI::PostInit @0x004EFD90` +Binds name `0x10000231`, heritage line `0x10000232`, PK line +`0x10000233`, level `0x1000023B`, total XP `0x10000235`, XP-to-level +`0x10000238` + meter `0x10000236`, luminance pair `0x100005C5/C6`, list +box `0x1000023D`. The refresh (vtable slot, near +`UpdateExperience @0x004F0A70`) composes the heritage line WITH the +display title; the PK strings are exactly "Player Killer" / +"Player Killer Lite" / "Non-Player Killer" (IsPK / IsPKLite — +cross-anchor `CharExamineUI::SetAppraiseInfo @0x004B45F0`). CT5 reads +the composing function verbatim before writing a line of C#. + +### Already in-tree +- Tab/page ids wired (`TabTitlesId 0x10000538`, `TitlesPageId + 0x10000539`); pages currently show retail-authored closed visuals. +- Header labels partially bound (`StatHeaderLine` + `PkStatus` seams + exist in `CharacterStatController.Bind` — content contract wrong). +- `GameEventType.CharacterTitle/UpdateTitle` enum entries exist; no + parser, no state owner, no outbound builder. +- The character window registers with `DatConstraintSource` — authored + min/max plumbing exists in `RetailWindowFrame`; Y-resize for this + window and the list-scrollbar contract do not. + +## Slices + +**CT1 — DAT ground truth + pins.** Live-DAT probe of layout +`0x2100002E`: attribute/skill row templates (icon x/y vs our hand-built +rows), the value-column right margin, header element fonts/colors +(level `0x1000023B` color — item 5), Titles-page elements +(`0x1000052F/32/35` geometry, row template, scrollbar), window +min/max constraints. Output: research doc + InstalledDat pins (the +tooltip/scrollbar-pin pattern). No production changes. + +**CT2 — Runtime title ownership + wire.** Parse `0x0029`/`0x002B`; +locate the DAT title-string table `GetCharacterTitleFromID` reads and +port the lookup; `RuntimeCharacterState` owns the title set + display +title (J4.3 owner; clears at generation reset); outbound `TitleSet` +builder behind a typed Runtime command; ordered change events for UI +and headless bots (#368 contract: hosts observe the same owner). +Conformance tests against ACE's writer shapes. + +**CT3 — Titles page UI.** Bind the authored page through the standard +GUI classes (`UiTemplateListBox`/`UiScrollbar`/`UiButton` — zero +bespoke widgets): sorted rows via the ported title-table lookup, +selection, ghost-when-current logic (state 0xd contract), display-title +text, Set-as-Display round trip, scrollbar. Retires half of AP-109. + +**CT4 — Header identity block.** Retail composition: name; " + "; PK status line — authored fonts/colors +(pure white per probe), live refresh on display-title change and PK +status, identical on Attributes AND Skills pages. Level color from the +authored element. Retires the rest of AP-109's UI half. + +**CT5 — Row alignment + value gutter.** Reconcile our hand-built +attribute/skill rows with the authored row templates from CT1: icon +placement, name/value columns, the authored right margin that reserves +the scrollbar gutter. + +**CT6 — Resize + scrollbar contract.** Character window Y-resizable to +the authored minimum; the stat list shows its scrollbar when the +resized viewport overflows (the full-track/disabled behavior from the +2026-08-24 scrollbar work applies as-is); authored min/max constraint +enforcement verified as the STANDARD path for every registered window +(one shared mechanism in `RetailWindowFrame`/`RetailWindowManager`, +no per-window special cases). + +**CT7 — Connected gate.** Test script +(`docs/research/2026-08-24-campaign-ct-test-script.md`), owner drive: +titles round trip against ACE (earn/set/display), header lines vs +retail side-by-side, resize behavior, row alignment screenshots. + +## Review protocol + +Per slice: Sonnet implements → Opus dual-lens review (lens 1 +retail-faithfulness vs the cited decomp anchors; lens 2 architecture — +GUI-class standardization, Runtime ownership boundaries, no +controller-side state) → fix round → full hermetic suite green. +Commits to the worktree branch as slices land; **no gitea push until +the owner directs it**. From 872b227209854b9c80b7a7777d9a141dbef70722 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 21:08:05 +0200 Subject: [PATCH 32/89] =?UTF-8?q?docs(CT):=20CT4=20header=20contract=20rea?= =?UTF-8?q?d=20verbatim=20=E2=80=94=20UpdateCharacterInfo=20@0x004F0770=20?= =?UTF-8?q?+=20UpdatePKStatus=20@0x004F00A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Name via AllegianceData::GetFullName, heritage line composed with the display title, level fallback literal, PK line from StringTable 0x23000001 ID_StatManagement_Header_PKStatus_* keys (the chat-label mechanism). Co-Authored-By: Claude Fable 5 --- ...6-08-24-character-panel-parity-campaign.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index 8de1aad3..126cae02 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -70,6 +70,30 @@ display title; the PK strings are exactly "Player Killer" / cross-anchor `CharExamineUI::SetAppraiseInfo @0x004B45F0`). CT5 reads the composing function verbatim before writing a line of C#. +### CT4 contract (read verbatim 2026-08-24, Fable) +`gmStatManagementUI::UpdateCharacterInfo @0x004F0770`: +- **Name line** (`0x10000231`): `ACCWeenieObject::GetObjectName(player, + NAME_SINGULAR)` run through `AllegianceData::GetFullName` with a + default-constructed allegiance record (implementer reads GetFullName + before porting — it may add rank prefixes when allegiance data exists). +- **Heritage line** (`0x10000232`): + `AppraisalSystem::InqGenderHeritageDisplay(gender=Int 0x71, + heritage=Int 0xBC, 0)` ("Female Aluvian"); then, when + `CharacterTitleTable::GetCharacterTitleFromID(m_titleID)` resolves, + `AppendText(separator @data_794358)` + `AppendText(titleString)` → + "Female Aluvian War Mage". Recover the separator literal with the + PE-read recipe (`claude-memory/reference_pe_byte_decode.md`). +- **Level** (`0x1000023B`): `InqInt(0x19)` present → + `Formatted(@data_7a0184)`; absent → literal `@data_7b0f34`. +- **PK line** (`0x10000233`): `UpdatePKStatus @0x004F00A0` — three-way + `IsPK` / `IsPKLite` / neither → StringInfo from table enum + `0x10000001` (= StringTable `0x23000001`, same compute_str_hash + mechanism the chat labels use) with keys + `ID_StatManagement_Header_PKStatus_PK` / `_PKL` / `_NPK`. +- Related key family for the footer/meter (already-shipped surfaces — + audit only if CT1 finds drift): `ID_StatManagement_Footer_*`, + `ID_StatManagement_Header_XPToLevelMeterInfinity`. + ### Already in-tree - Tab/page ids wired (`TabTitlesId 0x10000538`, `TitlesPageId 0x10000539`); pages currently show retail-authored closed visuals. From 45e276d38055224009365c5e13839fedce3ff9f7 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 21:08:45 +0200 Subject: [PATCH 33/89] =?UTF-8?q?docs(CT):=20name-line=20contract=20closed?= =?UTF-8?q?=20=E2=80=94=20AllegianceData::GetFullName=20@0x005B6950=20read?= =?UTF-8?q?=20verbatim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-24-character-panel-parity-campaign.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index 126cae02..ae1eaf35 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -73,9 +73,11 @@ the composing function verbatim before writing a line of C#. ### CT4 contract (read verbatim 2026-08-24, Fable) `gmStatManagementUI::UpdateCharacterInfo @0x004F0770`: - **Name line** (`0x10000231`): `ACCWeenieObject::GetObjectName(player, - NAME_SINGULAR)` run through `AllegianceData::GetFullName` with a - default-constructed allegiance record (implementer reads GetFullName - before porting — it may add rank prefixes when allegiance data exists). + NAME_SINGULAR)` through `AllegianceData::GetFullName @0x005B6950` + (read verbatim): when `AllegianceSystem::GetTitle(rank, heritage, + gender)` yields an allegiance rank title, the line is + ""; otherwise the plain name. + The owner's retail screenshot (plain "Dww") is the rankless case. - **Heritage line** (`0x10000232`): `AppraisalSystem::InqGenderHeritageDisplay(gender=Int 0x71, heritage=Int 0xBC, 0)` ("Female Aluvian"); then, when From ca4100e76a25f7c3ab2cea6af3287c330419551d Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 21:31:16 +0200 Subject: [PATCH 34/89] docs #CT1: character-panel DAT ground truth + InstalledDat pins Campaign CT slice CT1 (docs/plans/2026-08-24-character-panel-parity-campaign.md): establishes the authored ground truth for LayoutDesc 0x2100002E that CT2-CT6 build against, so those slices port against verified DAT facts instead of guessing. No production code changed. Header findings: the PK line is authored pure white (the CT4 bug is in CharacterStatController's runtime color choice, not a DAT gap); the level color is a pale-gold (1, 0.949, 0.498) WITH an authored outline, diverging from the current hardcoded Gold constant. The stat ListBox's row-template list (LayoutDesc 0x21000045) is unreachable via the whole-layout ImportInfos overload (the #375 same-layout template-list skip filter) -- the targeted ImportInfos(dats, layoutId, elementId) overload is required, same as UiTemplateListBox's TemplateResolver already uses. The shared attribute/skill row template (0x10000248) authors a 20x20 icon flush at X=0 (current code: 16x16 at X=4), fixed 150px/100px name/value columns at X=25/X=175 (current code: a width-fraction split), and a 7px gap between the value's right edge and the row's own edge -- the scrollbar-gutter margin the owner reported missing. The Titles page roster, row template (LayoutDesc 0x2100005E), and window constraints are also pinned; the character window's root authors NO min/max size properties at all (unlike chat's self-contained window layout), and RetailUiRuntime.MountCharacter never wires DatConstraintSource -- correcting the plan's "already in-tree" claim for CT6. Also derives and pins the full CharacterTitleTable::GetCharacterTitleFromID chain (title id -> EnumMapper(0x22000041) canonical name -> compute_str_hash -> StringTable(0x2300000E) localized text), resolved via the two-level DBObj::GetDIDByEnum master-map indirection (0x25000000 -> category map -> target DID) and verified end to end against ACE's CharacterTitle.WarMage=13 -> "War Mage". This independently cross-validates RetailKeyNames' existing 0x2300000A/0x2300000B/0x23000007 constants, which turn out to be the same category-4 map's enum 4/5/3 entries. Co-Authored-By: Claude Fable 5 --- ...2026-08-24-campaign-ct-dat-ground-truth.md | 396 ++++++++++++++++++ .../UI/Layout/CharacterPanelLiveDatTests.cs | 351 ++++++++++++++++ 2 files changed, 747 insertions(+) create mode 100644 docs/research/2026-08-24-campaign-ct-dat-ground-truth.md create mode 100644 tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs diff --git a/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md b/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md new file mode 100644 index 00000000..ad302fb4 --- /dev/null +++ b/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md @@ -0,0 +1,396 @@ +# Campaign CT slice CT1 — DAT ground truth for the character panel (0x2100002E) + +**Status:** RESEARCH COMPLETE 2026-08-24. No production code changed in this +slice. Findings feed CT2–CT6 (`docs/plans/2026-08-24-character-panel-parity-campaign.md`). + +Method: temporary `Assert.Fail` probe tests (deleted before commit; the +pattern is preserved in `tests/AcDream.App.Tests/UI/Layout/ScrollbarSkinLiveDatTests.cs` +and its sibling `CharacterPanelLiveDatTests.cs` written by this slice) against +the installed DAT set (`%USERPROFILE%\Documents\Asheron's Call`), driven +through `LayoutImporter.ImportInfos` / `ElementInfo` +(`src/AcDream.App/UI/Layout/ElementReader.cs`, +`src/AcDream.App/UI/Layout/LayoutImporter.cs`). + +## 1. Header elements (gmStatManagementUI content, sub-layout under 0x2100002E) + +All header elements are DUPLICATED — the imported 0x2100002E tree carries +two structurally identical copies of the whole header block, one reached +through the Attributes page chain (`0x10000227 > 0x1000022B > 0x10000226 > +0x10000230`) and one through the Skills page chain (`... > 0x1000022C > +...`). **The two copies are geometrically and stylistically IDENTICAL** — +this is the "duplicated stat-management branches" quirk documented on +`PrepareSkillScrollbar` in `CharacterStatController.cs`, confirmed here to +be a harmless duplicate (not a divergent one) for every header id checked. + +| Element | Id | X,Y | W×H | HJustify | VJustify | FontDid | FontColor | Outline | Margins | +|---|---|---|---|---|---|---|---|---|---| +| Name | 0x10000231 | 0,0 | 230×20 | Center | Center | 0x40000001 | white (1,1,1,1) | false | 0 | +| Heritage line | 0x10000232 | 0,20 | 230×15 | Center | Center | 0x40000002 | white (1,1,1,1) | false | L5 R5 | +| PK line | 0x10000233 | 0,35 | 230×15 | Center | Center | 0x40000002 | **white (1,1,1,1)** | false | 0 | +| Level | 0x1000023B | 235,35 | 65×50 | Center | Center | 0x40000010 | **(1, 0.9490196, 0.49803922, 1)** | **true** | 0 | +| Total XP | 0x10000235 | 130,70 | 100×18 | Right | Center | 0x40000000 | white | false | 0 | +| XP meter | 0x10000236 (Type 7) | 0,88 | 230×17 | — | — | — | — | — | — | +| XP-next label (child of meter) | 0x10000237 | 0,0 | 130×17 | Left | Center | 0x40000000 | white | false | 0 | +| XP-to-level value (child of meter) | 0x10000238 | 130,0 | 100×17 | Right | Center | 0x40000000 | white | false | 0 | +| Luminance label | 0x100005C5 | 0,52 | 110×18 | Left | Center | 0x40000000 | white | false | 0 | +| Luminance value | 0x100005C6 | 110,52 | 120×18 | Right | Center | 0x40000000 | white | false | 0 | + +Header block container (0x10000230) is 300×110 inside the shared prototype +layout 0x21000045 — the character content column itself is only 230px wide +(name/heritage/PK/XP all live in that 230px column), with a 5px vertical +divider (0x10000239, X=230, W=5) separating it from the level box +(X=235..300, matching the plan's "Level area (65,50)" spec). + +**Confirmations vs. the owner report / plan:** +- Item 4 (PK line pure white): CONFIRMED — `0x10000233` FontColor is + exactly white, not the "color off" state the owner reported. The bug is + purely in `CharacterStatController.Bind`'s runtime color choice (`Body` + = parchment `(0.92,0.90,0.82,1)`), not a DAT-reading gap. CT4 must switch + the PK-line color to `Vector4.One`. +- Item 5 (level color): CONFIRMED DIVERGENT. DAT-authored level color is + `(1, 0.9490196, 0.49803922, 1)` (a pale gold, ~RGB 255/242/127) **with + Outline=true**. `CharacterStatController.Gold` is currently + `(1, 0.82, 0.36, 1)` (a deeper orange-gold) with no outline applied. CT4 + should read the DAT FontColor + Outline directly instead of hand-picking + a runtime color, matching how `LevelId`'s dat FontDid is already honored. + +## 2. Stat list (0x1000023D) + row templates + +`0x1000023D` (Type 5 ListBox) is duplicated the same harmless way as the +header (once per Attributes/Skills page chain, identical geometry both +times): `X=0 Y=112 W=300 H=160`, `ScrollbarElementId=0x1000023E` +(scrollbar at `X=281 W=16 H=160`, i.e. always reserved, whether or not it's +shown). Its authored `TemplateList` (dat property 0x64) names FIVE +same-layout-family entries, all in **LayoutDesc 0x21000045**: + +``` +0x10000248, 0x10000249, 0x1000024A, 0x1000024B, 0x1000024C +``` + +These are NOT reachable by walking `ImportInfos(dats, 0x21000045u)`'s built +tree — `LayoutImporter.ImportInfos` intentionally filters out same-layout +template-list targets (the `#375` fix documented in +`LayoutImporter.ImportInfos`'s own comment: "retail never instantiates a +template-list element as a live widget… building them here parked two live +prototype rows… over and outside the framed panel"). The correct read path +— and the one CT5 must use — is the **targeted single-root overload**: +`LayoutImporter.ImportInfos(dats, 0x21000045u, templateElementId)`, the same +seam `UiTemplateListBox`'s `TemplateResolver` already uses for other +authored row templates. + +Dumping all five with that overload: + +### 0x10000248 — the ONE shared data-row template (icon + name + value) + +``` +Id=0x10000248 Type=3 (container) X=0 Y=0 W=282 H=20 + StateMedia[Normal] File=0x06004CC2 DrawMode=1 + StateMedia[Highlight] File=0x06000F93 DrawMode=1 + Id=0x10000129 Type=3 (icon slot) X=0 Y=0 W=20 H=20 (no own media — set per-row at runtime) + Id=0x1000012A Type=0xC (name text) X=25 Y=0 W=150 H=20 HJustify=Left FontDid=0x40000001 white + Id=0x1000012B Type=0xC (value text)X=175 Y=0 W=100 H=20 HJustify=Right FontDid=0x40000001 white +``` + +This is the single row template used for BOTH the attribute rows AND the +skill rows (retail's `gmAttributeUI`/`gmSkillUI` share it). Ground truth +for item 1 (icon alignment) and item 2 (value-column gutter): + +- **Icon: 20×20, flush at the row's left edge (X=0), full row height.** + Current code (`CharacterStatController.IconSize = 16f`, + `RowPadX = 4f`) draws a 16×16 icon at X=4 — smaller AND offset from + retail's flush-left 20×20. This is the "icons misaligned" bug (item 1). +- **Name column: X=25, W=150 (fixed pixel widths, not a width fraction).** + Current code computes `nameX = RowPadX + IconSize + IconGap` (4+16+6=26, + off by one from retail's 25) and `nameW = width * 0.60` (a + content-relative fraction retail does not use at all — retail's name + column is a FIXED 150px regardless of the 282px row width). +- **Value column: X=175, W=100, right-justified.** Value's right edge + sits at X=275. **The row template itself is 282px wide, so there is a + 7px gap between the value's right edge and the row's own right edge** + — this is the "authored margin between the value column and the + border" the owner reported (item 2), confirmed as exactly 7px at the + row-template level. +- **The row (282px) is already inset from the ListBox's full width + (300px) by 18px** to clear the always-reserved scrollbar gutter + (scrollbar at X=281, W=16) — so the TOTAL space between the value + text's right edge and the ListBox's outer right edge is + `300 - 275 = 25px`, of which 18px is the permanent scrollbar gutter and + 7px is the row template's own inset. Both numbers matter for CT5: + the row width (282, confirmed correct — matches the existing + `SkillContentWidth = 282f` constant already in the code) and the + internal 175/100 value-column placement (not currently matched). +- Row background: `Normal` state file `0x06004CC2` (the same generic + panel-chrome fill used elsewhere client-wide), `Highlight` state file + **`0x06000F93`**. `CharacterStatController.RowHighlightSprite` is + currently `0x06001397u` — **this is a divergent constant**; CT5 should + either confirm `0x06001397` is deliberately used for a DIFFERENT + highlight surface (e.g. the vitals/skill list uses a shared sprite + elsewhere) or correct it to `0x06000F93` for the attribute/skill row + highlight specifically. Flagged, not fixed, in this slice. + +### 0x10000249 / 0x1000024A / 0x1000024B / 0x1000024C — skill SECTION HEADER captions + +``` +Id=0x10000249 Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F90 +Id=0x1000024A Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F86 +Id=0x1000024B Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F98 +Id=0x1000024C Type=0xC X=0 Y=0 W=280 H=20 HJustify=Left Margins L5 R5 FontDid=0x40000001 StateMedia[]=0x06000F89 +``` + +These are single full-width caption bars (no icon/name/value split), 280px +wide (2px narrower than the data row — no scrollbar-gutter inset needed +since they never scroll independently). Their sprites are an EXACT match +for the existing constants already in `CharacterStatController.cs`: + +| Constant | Value | Probe file | Match | +|---|---|---|---| +| `SkillHeaderSpecializedSprite` | 0x06000F90 | 0x10000249 | YES | +| `SkillHeaderTrainedSprite` | 0x06000F86 | 0x1000024A | YES | +| `SkillHeaderUntrainedSprite` | 0x06000F98 | 0x1000024B | YES | +| `SkillHeaderUnusableSprite` | 0x06000F89 | 0x1000024C | YES | + +These four are correctly ported already; no CT5 work needed here. + +## 3. Titles page (0x10000539 subtree, imported as part of 0x2100002E) + +`0x10000539` (Type 0x10000046, the Titles page container) is NOT +duplicated like the Attributes/Skills content — it appears once, as a +direct child of the tab-control root `0x10000227` (siblings with the +Attributes/Skills tab buttons and the Titles tab button `0x10000538`). +Geometry: `X=0 Y=25 W=300 H=575` (fills the window below the 25px tab bar). + +| Element | Id | X,Y | W×H | Notes | +|---|---|---|---|---| +| (unlabeled caption) | 0x1000052E | 8,20 | 270×18 | Left, margin L6, FontDid 0x40000001 white — likely "Current Title:" caption | +| **Current display title text** | **0x1000052F** | 8,40 | 270×18 | Center, margins L5 R5, FontDid 0x40000001 white | +| divider | 0x10000530 | 0,60 | 300×9 | sprite 0x06001420 | +| (unlabeled caption) | 0x10000531 | 8,70 | 270×18 | Left, margin L6 — likely "Titles Earned:" caption | +| **Title ListBox** | **0x10000532** | 8,90 | 270×455 | ScrollbarElementId=0x10000533; TemplateList: LayoutDid=0x2100005E, ElementId=0x10000536 | +| Title scrollbar | 0x10000533 | 280,90 | 16×455 | shared `RetailScrollbarChrome` media (thumb/up/down ids identical to the base skin pinned by `ScrollbarSkinLiveDatTests`) | +| divider | 0x10000534 | 0,550 | 300×9 | sprite 0x06001420 | +| **"Set as Display Title" button** | **0x10000535** | 53,560 | 200×32 | MinWidth=65, margins L7 R7, FontDid 0x40000001 white, DefaultState=**Ghosted** (matches the plan's `UpdateButtons` ghost-when-current contract); three-slice chrome children 0x100002CE/CF/D0 with Normal/Normal_rollover/Normal_pressed/**Ghosted** states each | + +### Title row template — LayoutDesc 0x2100005E, element 0x10000536 + +``` +Id=0x10000536 Type=3 (container) X=0 Y=0 W=270 H=24 + StateMedia[DirectState, key ""] File=0x06004CCA DrawMode=3 + StateMedia[Highlight] File=0x06001AAF DrawMode=1 + Id=0x10000537 Type=0xC (text) X=0 Y=0 W=270 H=24 HJustify=Left Margins L6 R6 FontDid=0x40000001 white +``` + +A single-line text row, no icon column (titles have no per-row icon in +retail) — 24px tall vs. the stat rows' 20px. Row width 270 matches the +ListBox content width exactly (`0x10000532` is 270 wide, with its +scrollbar `0x10000533` living OUTSIDE that width at X=280 — unlike the +stat list, the title row template does NOT need its own internal +scrollbar-gutter inset because the ListBox width itself already excludes +the scrollbar column). + +Same targeted-root import gotcha as the stat templates applies here: +`LayoutImporter.ImportInfos(dats, 0x2100005Eu)` (the whole-layout overload) +returns the SAME element (0x1000052D, root of a throwaway container) but +does NOT surface 0x10000536 as a reachable child — use +`LayoutImporter.ImportInfos(dats, 0x2100005Eu, 0x10000536u)` instead. + +## 4. Window min/max constraints + +### Character window (0x2100002E) root + +`LayoutImporter.ImportInfos(dats, 0x2100002Eu)` returns element +`0x10000227` (Type 0x8, TabControl) as the tree root — this IS the +top-level element retail's `RetailUiRuntime.MountCharacter()` mounts via +`RetailWindowFrame.Mount(..., layout.Root, ...)`. **It authors NO +MinWidth/MinHeight/MaxWidth/MaxHeight properties** (dat properties +0x3F/0x3E/0x3D/0x3C all absent — probe shows every one of `MinW/MinH/MaxW/MaxH` +blank for 0x10000227 and every element under it, including the footer, +header, and Titles page). + +### Chat window (0x2100006F) root, for comparison + +`LayoutImporter.ImportInfos(dats, 0x2100006Fu)` returns element +`0x10000600` (Type 0x10000050, a self-contained "window" element that +directly includes its own dragbar (Type 2), border frame (Type 3), and +FOUR resize-grip corners (Type 9) as children — none of which the +character layout's root has). It DOES author constraints: +**MinWidth=300, MinHeight=100, MaxWidth=2000, MaxHeight=2000.** + +### Correction to the plan + +The plan's "Already in-tree" section states: *"The character window +registers with `DatConstraintSource` — authored min/max plumbing exists in +`RetailWindowFrame`; Y-resize for this window and the list-scrollbar +contract do not."* Read literally this implies the character window's +`RetailWindowFrame.Mount` call already sets `DatConstraintSource`. **It +does not.** `RetailUiRuntime.MountCharacter()` (`src/AcDream.App/UI/RetailUiRuntime.cs`, +~line 4043) constructs `RetailWindowFrame.Options` with `ResizeY = true`, +`ResizableEdges = ResizeEdges.Bottom`, `ConstrainResizeToParent = true` — +but **no `DatConstraintSource`, `MinHeight`, or `MaxHeight` field at +all**, unlike e.g. `MountSideVitals()` (~line 1474) which explicitly sets +`DatConstraintSource = info` from its own imported root. This is +consistent with what CT1 also found in the DAT itself: 0x2100002E's root +authors no size constraints to plumb through in the first place — chat's +window-frame elements are its own self-contained LayoutDesc, while +0x2100002E is CONTENT ONLY (tab bar + pages), with retail's window chrome +supplied by a separate mechanism. + +**Implication for CT6:** the character window's authored minimum height +(if one exists in retail) is not going to fall out of 0x2100002E's own +`MinHeight`/`MaxHeight` properties — those are simply absent. CT6 needs +to find where retail's `gmStatManagementUI`/its owning window-frame class +enforces a minimum window size (likely a hardcoded `ResizeTo`/`SetMinSize` +call in that class's C++, or a shared base window-frame behavior applied +uniformly — see the plan's own CT6 wording, "verified as the STANDARD path +for every registered window"). This is NOT a simple "read the DAT +property" fix like `MountSideVitals` was; treat the "Already in-tree" +plan bullet as **inaccurate** and start CT6 from the decomp for the +window-frame class instead of assuming the wiring is already 90% done. + +## 5. The title-string table (DAT ground truth for `CharacterTitleTable::GetCharacterTitleFromID`) + +### Decomp chain (named-retail, `docs/research/named-retail/acclient_2013_pseudo_c.txt`) + +`CharacterTitleTable::GetCharacterTitleFromID @0x005c6ed0` does **not** +read a StringTable directly. It goes through TWO independent +enum-mapper indirections: + +1. `EnumMapper::GetString(0x10000006, titleId, &rawName)` (the static + 3-arg overload @`0x0041ac40`) — internally calls + `DBObj::GetDIDByEnum(&did, 0x10000006, /*category*/1)` to resolve the + **title EnumMapper object's DID**, then dispatches on + `MasterDBMap::DivineType` (0x24 = EnumMapper) to call + `EnumMapper::GetString(titleId, &rawName)` on it, giving a raw + canonical string name (NOT yet localized/hashed). +2. `compute_str_hash(rawName)` (ELF-style hash, already ported byte-exact + as `DatStringResolver.ComputeHash` — see its own citation of + `compute_str_hash @ 0x00413110`). +3. `StringInfo::SetStringIDandTableEnum(&info, hash, 0x10000007)` + (`@0x0042c760`) — internally calls + `DBObj::GetDIDByEnum(&did, 0x10000007, /*category*/4)` to resolve the + **title StringTable's DID**. +4. `StringInfo::GetString(&info)` (`@0x0042e760` → `InqString` → + `StringTableMetaLanguage::UnescapeString`) resolves the final localized + text — the same `StringTable.Strings[hash]` lookup + `DatStringResolver.Resolve(tableId, stringId)` already performs. + +`DBObj::GetDIDByEnum(enumValue, category)` (`@0x004153a0` → +`DBCache::GetDIDFromEnum @0x00413940`) is itself a **two-level indirection** +through a master map object (`this->m_MasterMapID`): look up `category` +in the master map to get an intermediate category-map DID, then look up +`enumValue` in THAT map to get the final DID. This is the exact same +mechanism already ported (empirically, not by name) as `RetailKeyNames`' +`0x2300000A`/`0x2300000B`/`0x23000007` constants +(`src/AcDream.App/UI/Layout/RetailKeyNames.cs`, citing "`DBCache::GetDIDFromEnumStatic` +category 4") — this slice confirms those three constants ARE exactly the +category-4 (STRINGTABLE) map's enum 4/5/3 entries (see table below), so +the existing `RetailKeyNames` port is independently cross-validated by +this investigation. + +### Live-DAT resolution (verified end-to-end this session) + +`DatReaderWriter.DBObjs.EnumIDMap` (ACE's historical name: `DidMapper`, +file-type byte `0x25`) is the object type both master and category maps +use; `DatReaderWriter.DBObjs.EnumMapper` (file-type byte `0x22` on the +installed dat) is the flat id→string table type. + +**Master map, DID `0x25000000`** (`ClientEnumToID`/`ClientEnumToName`, +22 entries) — the categories relevant here: + +| category enum | name | category-map DID | +|---|---|---| +| 1 | EMAPPER | 0x25000001 | +| 4 | STRINGTABLE | 0x25000004 | + +**Category 1 (EMAPPER) map, DID `0x25000001`** — relevant entry: + +| enum | name | DID | +|---|---|---| +| 0x10000006 | CharacterTitle | **0x22000041** | + +**Category 4 (STRINGTABLE) map, DID `0x25000004`** — full dump (12 entries), +confirming the `RetailKeyNames` constants along the way: + +| enum | name | DID | cross-check | +|---|---|---|---| +| 0x00000003 | KeyMap | **0x23000007** | = `RetailKeyNames.DelimiterTableId` ✓ | +| 0x00000004 | KeyNameOverride | **0x2300000A** | = `RetailKeyNames.KeyNameTableId` ✓ | +| 0x00000005 | MetakeyNameOverride | **0x2300000B** | = `RetailKeyNames.MetaKeyNameTableId` ✓ | +| 0x10000007 | CharacterTitle | **0x2300000E** | (this slice's target) | +| 0x10000001 | UI | 0x23000001 | | +| 0x10000002 | UI_Pregame | 0x23000002 | | +| 0x10000003 | Preference | 0x23000003 | | +| 0x10000004 | UI_Options | 0x23000004 | | +| 0x10000006 | Options | 0x2300000D | | +| 0x00000002 | Calendar | 0x23000006 | | +| 0x00000006 | CommandSetup | 0x2300000C | | +| 0x00000007 | ActionDescription | 0x23000005 | | +| 0x00000008 | ServerEngine | 0x23000010 | | + +So: **the title EnumMapper is DID `0x22000041`; the title StringTable is +DID `0x2300000E`.** + +### End-to-end verification (ACE's `CharacterTitle` enum, `WarMage = 13`) + +``` +EnumMapper(0x22000041).IdToStringMap has 873 entries, including: + titleId 0 -> ID_CharacterTitle_Invalid + titleId 1 -> ID_CharacterTitle_Adventurer + titleId 5 -> ID_CharacterTitle_Life_Mage + titleId 13 -> ID_CharacterTitle_War_Mage + titleId 14 -> ID_CharacterTitle_Wayfarer + +ComputeHash("ID_CharacterTitle_War_Mage") = 0x0543AF05 +DatStringResolver(dats).Resolve(0x2300000E, 0x0543AF05) = "War Mage" +``` + +Byte-exact confirmation the chain is understood correctly end to end — +titleId 13 round-trips through the EnumMapper canonical-name lookup, the +retail hash function, and the StringTable localization lookup to produce +exactly "War Mage". + +### What CT2 needs to port + +1. Two `GetDIDByEnum`-shaped lookups (master map `0x25000000` → category + map → target DID) — CT2 can either hardcode the two resolved DIDs + (`0x22000041` for the EnumMapper, `0x2300000E` for the StringTable, the + way `RetailKeyNames` hardcodes its three) or port the two-level + indirection generically. Given `RetailKeyNames` already established the + "just hardcode the resolved DIDs, cite the probe" precedent for this + exact category-4 family, CT2 should follow the same precedent unless a + THIRD consumer of `GetDIDByEnum` appears that would justify factoring + out a shared helper. +2. `EnumMapper.IdToStringMap[titleId]` → raw canonical name (already + readable via `dats.Portal.TryGet`). +3. `DatStringResolver.ComputeHash(rawName)` (already exists, no new code). +4. `DatStringResolver.Resolve(0x2300000Eu, hash)` (already exists, no new + code) for the final localized display string. + +No new DAT-reading primitives are required — `EnumMapper`/`EnumIDMap` are +already exposed by `DatReaderWriter.DBObjs`, and `DatStringResolver` +already does steps 3–4 for other consumers. + +## Corrections to the plan (summary) + +1. **Window constraints are NOT already 90% wired.** The plan's + "Already in-tree" bullet claims `DatConstraintSource` registration for + the character window; the actual `MountCharacter()` call sets no such + field, and the DAT layout itself authors no MinHeight/MaxHeight on its + root to source one from even if it were wired. CT6 needs decomp + research into where retail's authored minimum for this specific window + actually lives (likely a class-level constant/behavior, not a + per-window DAT property) before it can port anything. +2. **The row-template elements are not walkable via the normal + `ImportInfos(dats, layoutId)` overload.** `#375`'s prototype-skip logic + deliberately excludes same-layout template-list targets from the built + tree. CT5 must use `ImportInfos(dats, layoutId, elementId)` (the + targeted single-root overload) to read `0x10000248` + (`LayoutDesc 0x21000045`) and `0x10000536` (`LayoutDesc 0x2100005E`) — + documented here so CT5 doesn't waste a cycle rediscovering the same + "NOT FOUND" dead end this slice hit first. +3. **`RowHighlightSprite` may already be wrong.** The DAT's row-template + Highlight state uses `0x06000F93`; the current constant in + `CharacterStatController.cs` is `0x06001397`. Not fixed in this slice + (no production changes); flagged for CT5's review. +4. Everything else in the plan's "Retail recon" section (the Titles page + element roster, the header element ids, the PostInit binding order) + checks out exactly against the live DAT — no other corrections. diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs new file mode 100644 index 00000000..c8265233 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs @@ -0,0 +1,351 @@ +using System.Numerics; +using AcDream.App.UI.Layout; +using DatReaderWriter; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign CT slice CT1 (2026-08-24) pins: the DAT ground truth for the +/// character panel (LayoutDesc 0x2100002E) that slices CT2–CT6 build +/// against. Findings + raw probe excerpts live in +/// docs/research/2026-08-24-campaign-ct-dat-ground-truth.md. Follows +/// the durable-pin pattern of / +/// — assertions, not dumps — so a DAT +/// revision or importer regression that silently changes any of these +/// authored facts fails loudly instead of drifting unnoticed into CT2–CT6. +/// +/// Decomp anchors: header elements are bound by +/// gmStatManagementUI::PostInit @0x004EFD90; the Titles page by +/// gmCharacterTitleUI::PostInit @0x0049A610. +/// +[Trait("Lane", "InstalledDat")] +public sealed class CharacterPanelLiveDatTests +{ + private static string DatDirectory => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + + private static IEnumerable Flatten(ElementInfo e) + { + yield return e; + foreach (var c in e.Children) + foreach (var d in Flatten(c)) + yield return d; + } + + /// + /// Header identity + level elements, bound by + /// gmStatManagementUI::PostInit @0x004EFD90. Pins item 4 (the PK + /// line is authored pure white — the CT4 bug is in the controller's + /// runtime color choice, NOT a DAT-reading gap) and item 5 (the level + /// color is a pale gold with an authored outline, not + /// CharacterStatController's current deeper-orange + /// Gold constant). Both header occurrences (Attributes-page and + /// Skills-page chains) are asserted identical — the harmless + /// "duplicated stat-management branches" quirk documented on + /// PrepareSkillScrollbar. + /// + [InstalledDatFact] + public void HeaderElements_AuthorExpectedFontsAndColors() + { + using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + ElementInfo? tree = LayoutImporter.ImportInfos(dats, 0x2100002Eu); + Assert.NotNull(tree); + + var nameOccurrences = Flatten(tree!).Where(e => e.Id == CharacterStatController.NameId).ToList(); + Assert.Equal(2, nameOccurrences.Count); // Attributes-page + Skills-page duplicate chains + foreach (var name in nameOccurrences) + { + Assert.Equal(0x40000001u, name.FontDid); + Assert.Equal(Vector4.One, name.FontColor); + } + + foreach (var heritage in Flatten(tree!).Where(e => e.Id == CharacterStatController.HeritageId)) + { + Assert.Equal(0x40000002u, heritage.FontDid); + Assert.Equal(Vector4.One, heritage.FontColor); + Assert.Equal(5, heritage.MarginLeft); + Assert.Equal(5, heritage.MarginRight); + } + + // Item 4: PK status line is authored PURE WHITE. + foreach (var pk in Flatten(tree!).Where(e => e.Id == CharacterStatController.PkStatusId)) + { + Assert.Equal(0x40000002u, pk.FontDid); + Assert.Equal(Vector4.One, pk.FontColor); + } + + // Item 5: level color is a pale gold (~RGB 255/242/127) WITH an + // authored outline — CharacterStatController.Gold (1, 0.82, 0.36, 1) + // with no outline is a divergent hand-picked runtime color. + foreach (var level in Flatten(tree!).Where(e => e.Id == CharacterStatController.LevelId)) + { + Assert.Equal(0x40000010u, level.FontDid); + Assert.True(level.Outline); + Assert.NotNull(level.FontColor); + Assert.Equal(1f, level.FontColor!.Value.X, precision: 3); + Assert.Equal(0.949f, level.FontColor!.Value.Y, precision: 2); + Assert.Equal(0.498f, level.FontColor!.Value.Z, precision: 2); + } + + foreach (var xpLabel in Flatten(tree!).Where(e => e.Id == CharacterStatController.XpNextLabelId)) + Assert.Equal(0x40000000u, xpLabel.FontDid); + foreach (var xpValue in Flatten(tree!).Where(e => e.Id == CharacterStatController.XpNextValueId)) + Assert.Equal(0x40000000u, xpValue.FontDid); + } + + /// + /// The stat ListBox (0x1000023D) authors a five-entry template + /// list in LayoutDesc 0x21000045: one shared icon+name+value data + /// row (0x10000248) plus four skill section-header captions + /// (0x10000249..0x1000024C). Pins the roster + the ListBox's own + /// authored scrollbar linkage. + /// + [InstalledDatFact] + public void StatListBox_AuthorsFiveRowTemplatesInSharedLayout() + { + using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + ElementInfo? tree = LayoutImporter.ImportInfos(dats, 0x2100002Eu); + Assert.NotNull(tree); + + // Duplicated once per Attributes-page/Skills-page chain, same as the + // header block — both copies are asserted identical. + var listBoxes = Flatten(tree!).Where(e => e.Id == CharacterStatController.ListBoxId).ToList(); + Assert.Equal(2, listBoxes.Count); + uint[] expected = + { + 0x10000248u, 0x10000249u, 0x1000024Au, 0x1000024Bu, 0x1000024Cu, + }; + foreach (ElementInfo listBox in listBoxes) + { + Assert.Equal(CharacterStatController.ListScrollbarId, listBox.ScrollbarElementId); + Assert.Equal(5, listBox.TemplateList.Count); + foreach (uint id in expected) + { + Assert.Contains( + listBox.TemplateList, + t => t.TemplateLayoutId == 0x21000045u && t.TemplateElementId == id); + } + } + } + + /// + /// The shared attribute/skill data-row template (0x10000248 in + /// LayoutDesc 0x21000045) — the icon/name/value geometry CT5 + /// implements against. NOTE: this element is intentionally unreachable + /// via 's + /// whole-layout overload (the #375 same-layout template-list skip + /// filter) — the targeted single-root overload + /// (ImportInfos(dats, layoutId, elementId)) is required, the same + /// seam UiTemplateListBox's TemplateResolver already uses. + /// + [InstalledDatFact] + public void AttributeRowTemplate_IconIsFlushLeftTwentyPixels_NameAndValueAreFixedColumns() + { + using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + ElementInfo? row = LayoutImporter.ImportInfos(dats, 0x21000045u, 0x10000248u); + Assert.NotNull(row); + + Assert.Equal(282f, row!.Width); + Assert.Equal(20f, row.Height); + Assert.Equal(0x06004CC2u, row.StateMedia["Normal"].File); + // Row-template Highlight sprite (0x06000F93) — CharacterStatController's + // current RowHighlightSprite private constant is 0x06001397, a + // divergence flagged (not fixed) by CT1; see the research doc's + // "Corrections to the plan" §3. + Assert.Equal(0x06000F93u, row.StateMedia["Highlight"].File); + + ElementInfo icon = Assert.Single(row.Children, c => c.Id == 0x10000129u); + Assert.Equal(0f, icon.X); + Assert.Equal(0f, icon.Y); + Assert.Equal(20f, icon.Width); + Assert.Equal(20f, icon.Height); + + ElementInfo name = Assert.Single(row.Children, c => c.Id == 0x1000012Au); + Assert.Equal(25f, name.X); + Assert.Equal(150f, name.Width); + Assert.Equal(HJustify.Left, name.HJustify); + Assert.Equal(0x40000001u, name.FontDid); + + ElementInfo value = Assert.Single(row.Children, c => c.Id == 0x1000012Bu); + Assert.Equal(175f, value.X); + Assert.Equal(100f, value.Width); + Assert.Equal(HJustify.Right, value.HJustify); + Assert.Equal(0x40000001u, value.FontDid); + + // Item 2: 7px gap between the value's right edge (275) and the row's + // own right edge (282) — the authored margin the owner reported. + Assert.Equal(7f, row.Width - (value.X + value.Width)); + } + + /// + /// The four skill section-header caption templates already match + /// CharacterStatController's existing sprite constants exactly — + /// a regression guard, not a bug pin (unlike the data-row template + /// above). + /// + [InstalledDatFact] + public void SkillSectionHeaderTemplates_MatchExistingSpriteConstants() + { + using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + + (uint elementId, uint expectedSprite)[] headers = + { + (0x10000249u, 0x06000F90u), // SkillHeaderSpecializedSprite + (0x1000024Au, 0x06000F86u), // SkillHeaderTrainedSprite + (0x1000024Bu, 0x06000F98u), // SkillHeaderUntrainedSprite + (0x1000024Cu, 0x06000F89u), // SkillHeaderUnusableSprite + }; + foreach (var (elementId, expectedSprite) in headers) + { + ElementInfo? header = LayoutImporter.ImportInfos(dats, 0x21000045u, elementId); + Assert.NotNull(header); + Assert.Equal(280f, header!.Width); + Assert.Equal(20f, header.Height); + Assert.Equal(expectedSprite, header.StateMedia[""].File); + } + } + + /// + /// The Titles page (gmCharacterTitleUI::PostInit @0x0049A610) + /// element roster: display-title text 0x1000052F, title ListBox + /// 0x10000532 (with its scrollbar linkage + row-template + /// reference), "Set as Display Title" button 0x10000535 (with the + /// authored Ghosted default state matching the ghost-when-current + /// contract). + /// + [InstalledDatFact] + public void TitlesPage_ElementRosterExists() + { + using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + ElementInfo? tree = LayoutImporter.ImportInfos(dats, 0x2100002Eu); + Assert.NotNull(tree); + + ElementInfo page = Assert.Single(Flatten(tree!), e => e.Id == 0x10000539u); + Assert.Equal(300f, page.Width); + Assert.Equal(575f, page.Height); + + ElementInfo displayTitle = Assert.Single(page.Children, c => c.Id == 0x1000052Fu); + Assert.Equal(0x40000001u, displayTitle.FontDid); + Assert.Equal(HJustify.Center, displayTitle.HJustify); + + ElementInfo listBox = Assert.Single(page.Children, c => c.Id == 0x10000532u); + Assert.Equal(5u, listBox.Type); // Type-5 ListBox + Assert.Equal(0x10000533u, listBox.ScrollbarElementId); + UiTemplateListEntry template = Assert.Single(listBox.TemplateList); + Assert.Equal(0x2100005Eu, template.TemplateLayoutId); + Assert.Equal(0x10000536u, template.TemplateElementId); + + ElementInfo setDisplayButton = Assert.Single(page.Children, c => c.Id == 0x10000535u); + Assert.Equal(1u, setDisplayButton.Type); // Type-1 button + Assert.Equal(65, setDisplayButton.MinWidth); + Assert.Equal("Ghosted", setDisplayButton.DefaultStateName); + } + + /// + /// The title row template (0x10000536 in LayoutDesc + /// 0x2100005E) — a single-line text row, no icon column, 24px + /// tall. Same targeted-root-overload requirement as the stat row + /// templates. + /// + [InstalledDatFact] + public void TitleRowTemplate_IsSingleLineTextRowWithNoIconColumn() + { + using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + ElementInfo? row = LayoutImporter.ImportInfos(dats, 0x2100005Eu, 0x10000536u); + Assert.NotNull(row); + + Assert.Equal(270f, row!.Width); + Assert.Equal(24f, row.Height); + // DirectState (the "" key), not a named "Normal" state. + Assert.Equal(0x06004CCAu, row.StateMedia[""].File); + Assert.Equal(0x06001AAFu, row.StateMedia["Highlight"].File); + + ElementInfo text = Assert.Single(row.Children); + Assert.Equal(0x10000537u, text.Id); + Assert.Equal(270f, text.Width); + Assert.Equal(24f, text.Height); + Assert.Equal(HJustify.Left, text.HJustify); + Assert.Equal(6, text.MarginLeft); + Assert.Equal(6, text.MarginRight); + Assert.Equal(0x40000001u, text.FontDid); + } + + /// + /// Item 3/7 ground truth: the character window's imported root + /// (0x10000227, the Type-8 TabControl ImportInfos returns + /// and RetailUiRuntime.MountCharacter mounts) authors NO + /// MinWidth/MinHeight/MaxWidth/MaxHeight — unlike e.g. the side-vitals + /// window (LayoutDesc 0x21000075) whose root DOES author + /// 0x3C..0x3F directly. Pinned so CT6 doesn't waste a cycle assuming a + /// simple "read the DAT property" fix is available for this window; + /// see the research doc's "Corrections to the plan" §1. + /// + [InstalledDatFact] + public void CharacterWindowRoot_AuthorsNoSizeConstraints() + { + using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + ElementInfo? tree = LayoutImporter.ImportInfos(dats, 0x2100002Eu); + Assert.NotNull(tree); + Assert.Equal(0x10000227u, tree!.Id); + Assert.Equal(8u, tree.Type); // Type-8 TabControl + + Assert.Null(tree.MinWidth); + Assert.Null(tree.MinHeight); + Assert.Null(tree.MaxWidth); + Assert.Null(tree.MaxHeight); + } + + /// + /// Comparison point for the pin above: the chat window's root DOES + /// author explicit size constraints, because unlike the character + /// panel's content-only layout, 0x2100006F's root IS a + /// self-contained window element (its own dragbar/border/resize-grip + /// children). + /// + [InstalledDatFact] + public void ChatWindowRoot_AuthorsExplicitSizeConstraints() + { + using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + ElementInfo? tree = LayoutImporter.ImportInfos(dats, 0x2100006Fu); + Assert.NotNull(tree); + Assert.Equal(0x10000600u, tree!.Id); + + Assert.Equal(300, tree.MinWidth); + Assert.Equal(100, tree.MinHeight); + Assert.Equal(2000, tree.MaxWidth); + Assert.Equal(2000, tree.MaxHeight); + } + + /// + /// The title-string table chain + /// (CharacterTitleTable::GetCharacterTitleFromID @0x005c6ed0): + /// title id → EnumMapper(0x22000041) canonical name → + /// compute_str_hashStringTable(0x2300000E) localized + /// text. Both DIDs are resolved via the two-level + /// DBObj::GetDIDByEnum master-map chain (master map + /// 0x25000000 → category map → target DID) — see the research + /// doc §5 for the full derivation. Verified end to end against ACE's + /// CharacterTitle.WarMage = 13. + /// + [InstalledDatFact] + public void TitleStringTable_ResolvesWarMageEndToEnd() + { + using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + + bool gotMapper = dats.Portal.TryGet( + 0x22000041u, out var titleEnumMapper); + Assert.True(gotMapper); + Assert.NotNull(titleEnumMapper); + string raw = titleEnumMapper!.IdToStringMap[13u].ToString(); + Assert.Equal("ID_CharacterTitle_War_Mage", raw); + + uint hash = DatStringResolver.ComputeHash(raw); + Assert.Equal(0x0543AF05u, hash); + + var resolver = new DatStringResolver(dats); + string? resolved = resolver.Resolve(0x2300000Eu, hash); + Assert.Equal("War Mage", resolved); + } +} From c73e8c05397f77598a74556f792d6b7fad439114 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 21:32:19 +0200 Subject: [PATCH 35/89] =?UTF-8?q?docs(CT):=20CT1=20landed=20=E2=80=94=20le?= =?UTF-8?q?dger=20+=20three=20binding=20corrections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- ...6-08-24-character-panel-parity-campaign.md | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index ae1eaf35..5c2cbcfd 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -109,7 +109,26 @@ the composing function verbatim before writing a line of C#. ## Slices -**CT1 — DAT ground truth + pins.** Live-DAT probe of layout +**CT1 — DAT ground truth + pins. LANDED `ca4100e7` (2026-08-24).** +Research: `docs/research/2026-08-24-campaign-ct-dat-ground-truth.md`; +9 InstalledDat pins in `CharacterPanelLiveDatTests`. Three corrections +now BINDING on later slices: +(a) the character root (`0x10000227`, Type-8 TabControl) authors NO +min/max constraints and `MountCharacter` wires no `DatConstraintSource` +— CT6 must first find retail's actual minimum mechanism in the decomp +(likely class behavior, not a DAT property); +(b) row templates are reachable ONLY via the targeted +`ImportInfos(dats, layoutId, elementId)` overload (`0x10000248` stat +row in `0x21000045`; `0x10000536` title row in `0x2100005E`) — the +plain import's #375 prototype-skip hides them; +(c) authored row geometry: icon 20x20 at X=0 (code: 16x16 at X=4), +name X=25 W=150, value X=175 W=100 right-justified, 7px gutter to the +282px row edge; row Highlight media is `0x06000F93` (code uses +`0x06001397` — flagged, CT5 verifies). +Title chain verified end-to-end: titleId → EnumMapper `0x22000041` +(canonical key, e.g. `ID_CharacterTitle_War_Mage`) → compute_str_hash → +StringTable `0x2300000E` → text (id 13 = "War Mage"). +Original scope: Live-DAT probe of layout `0x2100002E`: attribute/skill row templates (icon x/y vs our hand-built rows), the value-column right margin, header element fonts/colors (level `0x1000023B` color — item 5), Titles-page elements From d3877f1c0ef322e250dfe57e6618c39b5bf114fe Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 21:33:58 +0200 Subject: [PATCH 36/89] =?UTF-8?q?docs(CT):=20CT6=20research=20lead=20?= =?UTF-8?q?=E2=80=94=20clamp=20source=20is=20the=20generic=20resize=20path?= =?UTF-8?q?;=20probe=20panel-host=200x2100006E=20slots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../2026-08-24-character-panel-parity-campaign.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index 5c2cbcfd..9a4ad6c8 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -161,7 +161,17 @@ attribute/skill rows with the authored row templates from CT1: icon placement, name/value columns, the authored right margin that reserves the scrollbar gutter. -**CT6 — Resize + scrollbar contract.** Character window Y-resizable to +**CT6 — Resize + scrollbar contract.** CT6 research lead (Fable, +2026-08-24, follows CT1 correction (a)): `gmPanelUI::ResizeTo +@0x004BC6E0` is a bare tailcall to `UIElement::ResizeTo` — no clamp +there, so the minimum lives in the generic Resizebar drag path reading +element attributes, and the authoring to probe is the PANEL-HOST layout +`0x2100006E`'s slot elements (Character slot per `RetailPanelCatalog`, +sibling of social `0x1000018F`) — NOT the character layout root CT1 +probed. First CT6 step: probe the host slots' min/max + resize +authoring, then read `UIElement_Resizebar::StartMouseResizing +@0x0046B7E0`'s clamp source verbatim. +Original scope: Character window Y-resizable to the authored minimum; the stat list shows its scrollbar when the resized viewport overflows (the full-track/disabled behavior from the 2026-08-24 scrollbar work applies as-is); authored min/max constraint From e264d8392fe17437323dc663d006591fdfa60131 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 21:58:27 +0200 Subject: [PATCH 37/89] =?UTF-8?q?docs(CT):=20CT1=20fix=20round=20=E2=80=94?= =?UTF-8?q?=20sealed=20RowHighlightSprite=20verdict,=20verified=20resize?= =?UTF-8?q?=20mechanism,=20strengthened=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the Opus dual-lens review corrections to CT1's DAT ground-truth research (docs/research/2026-08-24-campaign-ct-dat-ground-truth.md): - Window constraints (BLOCKER): replaced the "likely a hardcoded ResizeTo/SetMinSize" guess with the verified mechanism — UIElement::ResizeTo clamps only via element attributes 0x3C-0x3F, nothing writes them at runtime, and retail resizes the SHARED gmPanelUI host (LayoutDesc 0x2100006E, slot 0x1000018E) rather than 0x2100002E's own content root. Flags the unresolved 300x600-vs-300x362 size tension for CT3/CT6 and marks the host elements NOT PROBED by CT1. - RowHighlightSprite upgraded from a flagged hedge to a SEALED VERDICT: the stat row's selected-state media is 0x06000F93 (gmAttributeUI::UpdateSelection -> InfoRegion::SetState on template 0x10000248), not 0x06001397 (which is legitimately the spellbook row's separate selected-overlay mechanism). Falsifies the matching comment in CharacterStatController.cs and dated-corrects the older 2026-06-26 doc at the spot that originated the wrong sprite id. - Replaced the "18px gutter + 7px = 25px" derived story with the bare authored rectangles (the numbers don't compose cleanly: 300-281=19, and the 282px row overlaps the 281px scrollbar band by 1px) — CT5 must implement the authored numbers directly, never a derived listWidth-18 formula. - Plan doc: corrected the UpdateButtons ghost rule (no selection -> Ghosted, not "ghosts when selected == current") and added the AddTitleToList row-write contract for CT3. - Pins: CharacterPanelLiveDatTests now honors ACDREAM_DAT_DIR first (matching InstalledDatFactAttribute and its sibling live-DAT test classes), hoists five vacuous bare-foreach assertions to counted .ToList() pins, and adds the stat ListBox + scrollbar rect pins that CT5/CT6 depend on. - Doc hygiene: marked several probe-session observations (header geometry "identical" claim, 0x06004CC2 characterization, the master-map/category-map dump) as unpinned inference vs. committed fact, corrected the 0x1000052D "throwaway container" mislabel, and stated the header table's parent-relative coordinate frame. - Recorded the CT5 gold this round found: InfoRegion::InfoRegion's icon-DID lookup (a third GetDIDByEnum consumer, category 0x10000002) and gmSkillUI::RebuildSkillList's section-header order confirmation, plus the RowHeight=22-vs-authored-20 divergence for attribute rows. Verified: ACDREAM_RUN_INSTALLED_DAT_TESTS=1 CharacterPanelLiveDatTests filter 9/9 green; hermetic App suite filter (CI's Lane exclusion list) 6111/6111 green. No production code changed. Co-Authored-By: Claude Fable 5 --- ...6-08-24-character-panel-parity-campaign.md | 17 +- ...06-26-character-window-retail-reference.md | 17 +- ...2026-08-24-campaign-ct-dat-ground-truth.md | 271 ++++++++++++++---- .../UI/Layout/CharacterPanelLiveDatTests.cs | 57 +++- 4 files changed, 297 insertions(+), 65 deletions(-) diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index 9a4ad6c8..223caa43 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -40,10 +40,19 @@ land; every deviation a slice introduces adds its row in the same commit. `AddTitleToList @0x0049A840` resolves the display string via `CharacterTitleTable::GetCharacterTitleFromID` (DAT title-string table — CT2 locates the DID) and inserts SORTED - (`FindSortedInsertPosition @0x0049A760`). -- `UpdateButtons @0x0049A500`: the display button GHOSTS (state 0xd) - when the selected row's title id == `mDisplayTitle`; Normal (1) - otherwise. Selection change (msg 4/0x43) re-runs it. + (`FindSortedInsertPosition @0x0049A760`). It writes the resolved title + text into row child `0x10000537` and stamps the row's id via + `SetAttribute_Enum(row, 0x1000008E, titleId)`, inserting the row via + `AddItemFromTemplateList(listBox, 0, insertPos)` — CT3 mirrors this + exact write shape when populating `0x10000532`. +- **CORRECTED (CT1 fix round 2026-08-24):** `UpdateButtons @0x0049A500` + — the display button is **GHOSTED (state 0xd) UNLESS a row is + SELECTED whose title id differs from the current display title; no + selection → Ghosted.** (Not "ghosts when selected == current" — that + phrasing had the no-selection case backwards.) Verbatim mechanism: + a no-match selection falls through to index `0xFFFFFFFF` → + `GetItem` returns null → `SetState(0xd)`. Selection change + (msg 4/0x43) re-runs it. - Clicking `0x10000535` sends `CM_Social::Event_SetDisplayCharacterTitle(titleId)` (`ListenToElementMessage @0x0049A6D0`). diff --git a/docs/research/2026-06-26-character-window-retail-reference.md b/docs/research/2026-06-26-character-window-retail-reference.md index 8ddb9c2a..949e3a7c 100644 --- a/docs/research/2026-06-26-character-window-retail-reference.md +++ b/docs/research/2026-06-26-character-window-retail-reference.md @@ -31,6 +31,20 @@ the remaining polish on acdream's `CharacterStatController` (LayoutDesc 0x210000 - **Selected row (Strength):** highlighted with a **DARKER background + bars above/below** — retail's selected-row sprite `0x06001397` (Button state 6). (✗ acdream uses a translucent GOLD tint — replace with the dark-bar sprite.) + + > **CORRECTION 2026-08-24 (Campaign CT, CT1 fix round).** The sprite id + > above is WRONG for this element. Campaign CT's live-DAT probe + > (`docs/research/2026-08-24-campaign-ct-dat-ground-truth.md`, §2 + > "SEALED VERDICT") found the attribute/skill row's actual Highlight + > media is `0x06000F93`, drawn via `gmAttributeUI::UpdateSelection + > @0x0049DEE0`'s `SetState(6)` → `InfoRegion::SetState @0x004F0EE0` on + > the row template `0x10000248` itself. `0x06001397` is real, but it + > belongs to a DIFFERENT mechanism: the spellbook row's separate + > selected-overlay child element (`0x10000342` under prototype + > `0x10000343`, via `UIElement_UIItem::SetSelectedState @0x004E1240`). + > This note's "Button state 6" framing was accidentally right about the + > STATE number but wrong about which sprite that state resolves to on + > this element. CT5 is the owning slice for the fix. - **Footer flips to:** - Title: **"Strength: 200"** — **WHITE** text (✗ acdream uses the body/gold color). - "Experience To Raise:" + **"Infinity!"** (Strength is maxed → cost is infinite; ✗ acdream shows a @@ -45,7 +59,8 @@ the remaining polish on acdream's `CharacterStatController` (LayoutDesc 0x210000 3. [ ] Add "Total Experience (XP):" caption. 4. [ ] Add "XP for next level:" caption + value (un-consume from the meter, or render alongside). 5. [ ] Row text larger (≈icon height) + rows tighter. -6. [ ] Selection highlight → sprite 0x06001397 (dark bars), not gold tint. +6. [ ] Selection highlight → sprite 0x06000F93 (dark bars), not gold tint. (Corrected 2026-08-24 — + see the CORRECTION note above; the sprite id in this checklist item was originally 0x06001397.) 7. [ ] Selected footer title → white. 8. [ ] Maxed attribute → "Experience To Raise: Infinity!". 9. [ ] Footer title wording = "Select an Attribute to Improve" (Attribute). diff --git a/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md b/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md index ad302fb4..ddb933ab 100644 --- a/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md +++ b/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md @@ -19,8 +19,21 @@ through the Attributes page chain (`0x10000227 > 0x1000022B > 0x10000226 > 0x10000230`) and one through the Skills page chain (`... > 0x1000022C > ...`). **The two copies are geometrically and stylistically IDENTICAL** — this is the "duplicated stat-management branches" quirk documented on -`PrepareSkillScrollbar` in `CharacterStatController.cs`, confirmed here to -be a harmless duplicate (not a divergent one) for every header id checked. +`PrepareSkillScrollbar` in `CharacterStatController.cs`. **Caveat: this +"identical" claim rests on the deleted probe tests, not the committed +pins.** `CharacterPanelLiveDatTests.HeaderElements_AuthorExpectedFontsAndColors` +only asserts font/color/margin equality across the two copies (and the +2-count) — it does not assert X/Y/W/H geometric equality between them. +Treat "geometrically identical" as probe-session observation, not a +pinned fact, until a future slice adds a geometry-equality assertion. + +**Coordinate frame:** every `X,Y` below is **parent-relative**, not +page-relative — e.g. Name `0,0` means the top-left of its immediate +parent container (`0x10000230`), not the top-left of the Attributes/ +Skills page. Cross-reference +`docs/research/2026-06-25-character-window-faithful-spec.md` for the +page-relative numbers if you need the header block's position within +the page itself; do not mix the two frames when placing elements. | Element | Id | X,Y | W×H | HJustify | VJustify | FontDid | FontColor | Outline | Margins | |---|---|---|---|---|---|---|---|---|---| @@ -110,23 +123,59 @@ for item 1 (icon alignment) and item 2 (value-column gutter): — this is the "authored margin between the value column and the border" the owner reported (item 2), confirmed as exactly 7px at the row-template level. -- **The row (282px) is already inset from the ListBox's full width - (300px) by 18px** to clear the always-reserved scrollbar gutter - (scrollbar at X=281, W=16) — so the TOTAL space between the value - text's right edge and the ListBox's outer right edge is - `300 - 275 = 25px`, of which 18px is the permanent scrollbar gutter and - 7px is the row template's own inset. Both numbers matter for CT5: - the row width (282, confirmed correct — matches the existing - `SkillContentWidth = 282f` constant already in the code) and the - internal 175/100 value-column placement (not currently matched). -- Row background: `Normal` state file `0x06004CC2` (the same generic - panel-chrome fill used elsewhere client-wide), `Highlight` state file - **`0x06000F93`**. `CharacterStatController.RowHighlightSprite` is - currently `0x06001397u` — **this is a divergent constant**; CT5 should - either confirm `0x06001397` is deliberately used for a DIFFERENT - highlight surface (e.g. the vitals/skill list uses a shared sprite - elsewhere) or correct it to `0x06000F93` for the attribute/skill row - highlight specifically. Flagged, not fixed, in this slice. +- **The bare authored rectangles** (no derived arithmetic — see the + caveat below): ListBox `0x1000023D` is `X=0 Y=112 W=300 H=160`; its + scrollbar `0x1000023E` is `X=281 W=16 H=160`; the data row template is + `W=282`; the value column inside the row is `X=175 W=100`, + right-justified, 7px from the row's own right edge (`282 - (175+100) = + 7`). +- **These numbers do NOT compose into a tidy "gutter" story — do not + infer one.** `300 - 281 = 19`, not the row's 282px width's complement + (`300 - 282 = 18`); and the 282px row actually OVERLAPS the 281px + scrollbar band by 1px (`281 < 282`). An earlier draft of this doc + described the row as "inset by 18px to clear an always-reserved + scrollbar gutter" and summed 18+7=25 — that decomposition does not + close against the authored numbers above and is **inference, not + fact**; withdrawn. CT5 must implement the authored numbers directly + (row width 282 — matches the existing `SkillContentWidth = 282f` + constant already in the code; value column X=175 W=100; scrollbar + X=281 W=16), never a derived `listWidth - 18` or similar formula. +- Row background: `Normal` state file `0x06004CC2`. **The description + "generic panel-chrome fill used elsewhere client-wide" is UNVERIFIED** + — no cross-reference sweep for other consumers of `0x06004CC2` was run + this slice; only the file id itself, as authored on this specific + template, is pinned. `Highlight` state file **`0x06000F93`**. + +### SEALED VERDICT: RowHighlightSprite is wrong, not merely flagged + +Retail's selected attribute/skill row draws the row template's Highlight +state media — `0x06000F93`, not `CharacterStatController`'s current +`0x06001397u`. This is no longer a hedge; the decomp confirms the +mechanism end to end: + +- `gmAttributeUI::UpdateSelection @0x0049DEE0` calls + `SetState(selected ? 6 : 1)` on the row. +- `InfoRegion::SetState @0x004F0EE0` forwards that state to the row + element instantiated from template `0x10000248` — the exact template + this slice dumped, whose Highlight-state file is `0x06000F93`. +- State `6` IS `UIStateId.Highlight` — so the selected row draws + `0x10000248`'s own `Highlight` media, not a separately-chosen sprite. + +`CharacterStatController.cs`'s comment near lines 111–113 ("matches +retail... sprite 0x06001397 visual intent") is **falsified for this +element**. CT5 must correct `RowHighlightSprite` to `0x06000F93` for the +STAT rows and should reconsider `UseSelectionBars`/`HighlightBg`, which +currently emulate the wrong sprite's art (a translucent gold tint tuned +to look like `0x06001397`'s dark bars, not `0x06000F93`'s actual look). + +**`0x06001397` is not a phantom constant — it is legitimate ELSEWHERE.** +The spellbook row prototype `0x10000343` has a separate selected-overlay +CHILD element `0x10000342` whose media IS `0x06001397` +(`UIElement_UIItem::SetSelectedState @0x004E1240` mechanism — a +different code path from `InfoRegion::SetState`, and a different visual +composition: an overlay child, not a state-swap on the row itself). CT5 +must correct the STAT rows ONLY and must NOT touch `SpellbookRowStyle.cs` +or its tests — `0x06001397` is correct there. ### 0x10000249 / 0x1000024A / 0x1000024B / 0x1000024C — skill SECTION HEADER captions @@ -151,6 +200,38 @@ for the existing constants already in `CharacterStatController.cs`: These four are correctly ported already; no CT5 work needed here. +### Row-height divergence (CT5 gold, found this fix round) + +The row template above authors `H=20` (line `Id=0x10000248 Type=3 +(container) X=0 Y=0 W=282 H=20`, already pinned by +`CharacterPanelLiveDatTests.AttributeRowTemplate_...`). Current code +matches this for skill rows (`CharacterStatController.SkillRowHeight = +20f`) but NOT for attribute rows, which use a separate +`CharacterStatController.RowHeight = 22f` constant. CT5 must fix the +attribute-row path to 20px; there is no authored basis for 22 anywhere +in the row template. + +### Row instantiation + icon-DID anchors (CT5 gold, found this fix round) + +`InfoRegion::InfoRegion @0x004F1450` instantiates each stat row via +`AddItemFromTemplateList(listBox, 0, ...)` — template index **0**, i.e. +`0x10000248`, the shared data row confirmed above — and binds +`0x1000012A` (label), `0x1000012B` (value), `0x10000129` (icon) via +`UIRegion::SetImageByDID(icon, did, 3)` (icon draw mode 3). Per-attribute +icon DIDs come from `DBObj::GetDIDByEnum(statEnum, category +0x10000002)` in `gmAttributeUI::PostInit @0x0049DB70` — a THIRD consumer +of the `GetDIDByEnum` master-map mechanism documented in §5 below +(alongside the title EnumMapper/StringTable pair and `RetailKeyNames`), +which is enough precedent that CT2/CT5 should factor a shared +`GetDIDByEnum(enumValue, category)` helper instead of hardcoding a third +independent DID pair. + +`gmSkillUI::RebuildSkillList @0x0049C3A0` adds template indices **1–4** +(`0x10000249..0x1000024C`) for the section-header captions, confirming +the section-header order already pinned by +`SkillSectionHeaderTemplates_MatchExistingSpriteConstants` above: +Specialized, Trained, Untrained, Unusable. + ## 3. Titles page (0x10000539 subtree, imported as part of 0x2100002E) `0x10000539` (Type 0x10000046, the Titles page container) is NOT @@ -188,10 +269,19 @@ scrollbar-gutter inset because the ListBox width itself already excludes the scrollbar column). Same targeted-root import gotcha as the stat templates applies here: -`LayoutImporter.ImportInfos(dats, 0x2100005Eu)` (the whole-layout overload) -returns the SAME element (0x1000052D, root of a throwaway container) but -does NOT surface 0x10000536 as a reachable child — use -`LayoutImporter.ImportInfos(dats, 0x2100005Eu, 0x10000536u)` instead. +`LayoutImporter.ImportInfos(dats, 0x2100005Eu)` (the whole-layout +overload) returns the SAME element `0x1000052D`, but does NOT surface +`0x10000536` as a reachable child. **`0x1000052D` is not a throwaway +container** — per the `BaseElement`/`BaseLayoutId` table in +`docs/research/2026-06-25-character-window-faithful-spec.md` (line +~19), `0x1000052D` is the authored `BaseElement` that the Titles page +root `0x10000539` inherits its content from (`0x10000539`'s +`BaseLayoutId` is `0x2100005E`, `BaseElement` is `0x1000052D`) — it is +the real authored Titles-page content, just reached by a different path +than the live-mounted tree. Use +`LayoutImporter.ImportInfos(dats, 0x2100005Eu, 0x10000536u)` (the +targeted single-root overload) to reach the row template `0x10000536` +underneath it. ## 4. Window min/max constraints @@ -234,17 +324,64 @@ window-frame elements are its own self-contained LayoutDesc, while 0x2100002E is CONTENT ONLY (tab bar + pages), with retail's window chrome supplied by a separate mechanism. -**Implication for CT6:** the character window's authored minimum height -(if one exists in retail) is not going to fall out of 0x2100002E's own -`MinHeight`/`MaxHeight` properties — those are simply absent. CT6 needs -to find where retail's `gmStatManagementUI`/its owning window-frame class -enforces a minimum window size (likely a hardcoded `ResizeTo`/`SetMinSize` -call in that class's C++, or a shared base window-frame behavior applied -uniformly — see the plan's own CT6 wording, "verified as the STANDARD path -for every registered window"). This is NOT a simple "read the DAT -property" fix like `MountSideVitals` was; treat the "Already in-tree" -plan bullet as **inaccurate** and start CT6 from the decomp for the -window-frame class instead of assuming the wiring is already 90% done. +Treat the "Already in-tree" plan bullet as **inaccurate**: start CT6 +from the decomp for the window-frame class instead of assuming the +wiring is already 90% done. The paragraph below replaces this doc's +earlier "likely a hardcoded ResizeTo/SetMinSize call" guess with the +verified mechanism. + +### Verified resize mechanism (2026-08-24, supersedes the hypothesis above) + +`UIElement::ResizeTo @0x00463C30` clamps ONLY via element attributes — +`0x3C` (clamp-max-height), `0x3E` (clamp-min-height), `0x3D` +(clamp-max-width), `0x3F` (clamp-min-width) — read off `this`, the +element actually being resized. A decomp-wide grep for writers of those +four attributes turns up NOTHING: no runtime code ever sets them at +runtime. The clamp source is exclusively **authored DAT properties on +whichever element `ResizeTo` is called against, full stop.** There is no +hardcoded `SetMinSize` call anywhere in the class hierarchy; the earlier +"likely a hardcoded ResizeTo/SetMinSize call" phrasing in this doc was a +guess and is WRONG. + +The element `ResizeTo` is called against is not `0x2100002E`'s own root. +Per `docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md` +(lines ~83-107) and the slot table in +`docs/research/2026-08-11-fa-panel-structure.md` (row for `0x1000018E`), +retail's Character/Skills tab content is one child slot inside the +SHARED `gmPanelUI` host, LayoutDesc `0x2100006E`: + +```text +gmPanelUI host 0x100005FE 310 x 372 +content parent 0x10000180 300 x 362 (anchored all edges) +Character/Skills slot 0x1000018E panel id 11, 300 x 362 +top-center Dragbar 0x1000065C Type 2 +bottom-center Resizebar 0x10000660 Type 9 +``` + +`gmPanelUI::ResizeTo @0x004BC6E0` is a bare tailcall into +`UIElement::ResizeTo` — the HOST is what gets resized (via its +Resizebar), not `0x2100002E`'s content root; the content root's own +absent MinHeight/MaxHeight (confirmed above) is therefore consistent +with retail's actual mechanism, not evidence of a missing DAT property. + +**NOT PROBED by CT1.** The host elements above (`0x100005FE`, +`0x10000180`, `0x1000018E`, `0x1000065C`, `0x10000660`) were read from +the cited pseudocode doc, not re-probed live against the installed DAT +this session. CT6's first step is to probe those host slots directly +(min/max + resize authoring) before porting anything, then read +`UIElement_Resizebar::StartMouseResizing @0x0046B7E0` verbatim for the +drag-time clamp application. + +**Size tension for CT3/CT6 to resolve.** `0x2100002E`'s own root is +authored **300×600** (the Titles page alone is 300×575, plus the 25px +tab bar = 600 — §3), but retail mounts that content into the host's +**300×362** slot (`0x1000018E`). CT3's title ListBox height (`0x10000532` +is 270×455 per §3) and CT6's resize contract both assume a taller +available area than the host slot's authored 362px. This doc does not +resolve which number governs at runtime (scroll-clipped content inside a +fixed slot vs. the slot itself growing to accommodate) — CT6 must +resolve it from the decomp before implementing the resize contract, not +infer it from either number in isolation. ## 5. The title-string table (DAT ground truth for `CharacterTitleTable::GetCharacterTitleFromID`) @@ -288,6 +425,15 @@ this investigation. ### Live-DAT resolution (verified end-to-end this session) +**The master-map / category-map tables and the `RetailKeyNames` +cross-validation below are UNPINNED probe output** — they come from the +same deleted `Assert.Fail` probe tests as the rest of this doc and are +not backed by a committed `InstalledDatFact` assertion (unlike the +`TitleStringTable_ResolvesWarMageEndToEnd` pin, which DOES commit the +final two DIDs and the end-to-end string resolution). Treat the +category-4 dump and the `RetailKeyNames` match column as this session's +observation, re-derivable from the DAT but not regression-guarded. + `DatReaderWriter.DBObjs.EnumIDMap` (ACE's historical name: `DidMapper`, file-type byte `0x25`) is the object type both master and category maps use; `DatReaderWriter.DBObjs.EnumMapper` (file-type byte `0x22` on the @@ -354,11 +500,15 @@ exactly "War Mage". map → target DID) — CT2 can either hardcode the two resolved DIDs (`0x22000041` for the EnumMapper, `0x2300000E` for the StringTable, the way `RetailKeyNames` hardcodes its three) or port the two-level - indirection generically. Given `RetailKeyNames` already established the - "just hardcode the resolved DIDs, cite the probe" precedent for this - exact category-4 family, CT2 should follow the same precedent unless a - THIRD consumer of `GetDIDByEnum` appears that would justify factoring - out a shared helper. + indirection generically. **A THIRD consumer has now appeared** (found + this fix round): `gmAttributeUI::PostInit @0x0049DB70` resolves + per-attribute icon DIDs via `DBObj::GetDIDByEnum(statEnum, category + 0x10000002)` — see the "Row instantiation + icon-DID anchors" note in + §2. With `RetailKeyNames` (category 4) and the title chain (categories + 1 and 4) already hardcoding resolved DIDs, this third independent + category (`0x10000002`) is the point where CT2/CT5 should factor a + shared `GetDIDByEnum(enumValue, category)` helper instead of adding a + fourth ad-hoc hardcoded pair. 2. `EnumMapper.IdToStringMap[titleId]` → raw canonical name (already readable via `dats.Portal.TryGet`). 3. `DatStringResolver.ComputeHash(rawName)` (already exists, no new code). @@ -371,14 +521,18 @@ already does steps 3–4 for other consumers. ## Corrections to the plan (summary) -1. **Window constraints are NOT already 90% wired.** The plan's - "Already in-tree" bullet claims `DatConstraintSource` registration for - the character window; the actual `MountCharacter()` call sets no such - field, and the DAT layout itself authors no MinHeight/MaxHeight on its - root to source one from even if it were wired. CT6 needs decomp - research into where retail's authored minimum for this specific window - actually lives (likely a class-level constant/behavior, not a - per-window DAT property) before it can port anything. +1. **Window constraints are NOT already 90% wired — and the clamp + mechanism is now VERIFIED, not guessed.** The plan's "Already in-tree" + bullet claims `DatConstraintSource` registration for the character + window; the actual `MountCharacter()` call sets no such field, and the + DAT layout itself authors no MinHeight/MaxHeight on its root to source + one from even if it were wired. `UIElement::ResizeTo @0x00463C30` + clamps only via element attributes `0x3C`–`0x3F`, which nothing writes + at runtime — the clamp source is always authored DAT properties on the + resized element, full stop, and the resized element is the SHARED + `gmPanelUI` host (`0x2100006E`, slot `0x1000018E`), not `0x2100002E`'s + own root. See §4's "Verified resize mechanism" for the full chain and + the unresolved 300×600-vs-300×362 size tension CT6 must still resolve. 2. **The row-template elements are not walkable via the normal `ImportInfos(dats, layoutId)` overload.** `#375`'s prototype-skip logic deliberately excludes same-layout template-list targets from the built @@ -387,10 +541,25 @@ already does steps 3–4 for other consumers. (`LayoutDesc 0x21000045`) and `0x10000536` (`LayoutDesc 0x2100005E`) — documented here so CT5 doesn't waste a cycle rediscovering the same "NOT FOUND" dead end this slice hit first. -3. **`RowHighlightSprite` may already be wrong.** The DAT's row-template - Highlight state uses `0x06000F93`; the current constant in - `CharacterStatController.cs` is `0x06001397`. Not fixed in this slice - (no production changes); flagged for CT5's review. +3. **`RowHighlightSprite` IS wrong — SEALED, not merely flagged.** The + DAT's row-template Highlight state is `0x06000F93`, reached via + `gmAttributeUI::UpdateSelection`'s `SetState(6)` → + `InfoRegion::SetState` on the row itself; the current constant in + `CharacterStatController.cs` is `0x06001397`, which belongs to a + DIFFERENT mechanism (the spellbook row's selected-overlay child, + `UIElement_UIItem::SetSelectedState`). CT5 must correct the STAT rows' + `RowHighlightSprite` to `0x06000F93` and must NOT touch + `SpellbookRowStyle.cs` — see §2's "SEALED VERDICT" note for the full + anchor chain. 4. Everything else in the plan's "Retail recon" section (the Titles page element roster, the header element ids, the PostInit binding order) checks out exactly against the live DAT — no other corrections. +5. **Several findings above are probe-session observations, not + committed pins** — flagged this fix round so CT2–CT6 don't cite them + as regression-guarded facts: the header block's "two copies + geometrically identical" claim (only fonts/colors are pinned, not + full geometry — §1), the `0x06004CC2` "generic panel chrome" + characterization (§2), and the master-map/category-map dump plus the + `RetailKeyNames` cross-validation table (§5). The title chain's final + two DIDs and end-to-end string resolution ARE pinned + (`TitleStringTable_ResolvesWarMageEndToEnd`). diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs index c8265233..65589b48 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs @@ -22,7 +22,8 @@ namespace AcDream.App.Tests.UI.Layout; public sealed class CharacterPanelLiveDatTests { private static string DatDirectory => - Path.Combine( + Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Documents", "Asheron's Call"); @@ -61,7 +62,14 @@ public sealed class CharacterPanelLiveDatTests Assert.Equal(Vector4.One, name.FontColor); } - foreach (var heritage in Flatten(tree!).Where(e => e.Id == CharacterStatController.HeritageId)) + // CT1 fix round: hoisted to .ToList() + an explicit count assertion. + // A bare `foreach (x in seq.Where(...))` with no count check passes + // vacuously if the importer ever returns an empty sequence — it + // proves nothing about the two Attributes/Skills duplicate chains + // actually being present. + var heritageOccurrences = Flatten(tree!).Where(e => e.Id == CharacterStatController.HeritageId).ToList(); + Assert.Equal(2, heritageOccurrences.Count); + foreach (var heritage in heritageOccurrences) { Assert.Equal(0x40000002u, heritage.FontDid); Assert.Equal(Vector4.One, heritage.FontColor); @@ -70,7 +78,9 @@ public sealed class CharacterPanelLiveDatTests } // Item 4: PK status line is authored PURE WHITE. - foreach (var pk in Flatten(tree!).Where(e => e.Id == CharacterStatController.PkStatusId)) + var pkOccurrences = Flatten(tree!).Where(e => e.Id == CharacterStatController.PkStatusId).ToList(); + Assert.Equal(2, pkOccurrences.Count); + foreach (var pk in pkOccurrences) { Assert.Equal(0x40000002u, pk.FontDid); Assert.Equal(Vector4.One, pk.FontColor); @@ -79,7 +89,9 @@ public sealed class CharacterPanelLiveDatTests // Item 5: level color is a pale gold (~RGB 255/242/127) WITH an // authored outline — CharacterStatController.Gold (1, 0.82, 0.36, 1) // with no outline is a divergent hand-picked runtime color. - foreach (var level in Flatten(tree!).Where(e => e.Id == CharacterStatController.LevelId)) + var levelOccurrences = Flatten(tree!).Where(e => e.Id == CharacterStatController.LevelId).ToList(); + Assert.Equal(2, levelOccurrences.Count); + foreach (var level in levelOccurrences) { Assert.Equal(0x40000010u, level.FontDid); Assert.True(level.Outline); @@ -89,9 +101,14 @@ public sealed class CharacterPanelLiveDatTests Assert.Equal(0.498f, level.FontColor!.Value.Z, precision: 2); } - foreach (var xpLabel in Flatten(tree!).Where(e => e.Id == CharacterStatController.XpNextLabelId)) + var xpLabelOccurrences = Flatten(tree!).Where(e => e.Id == CharacterStatController.XpNextLabelId).ToList(); + Assert.Equal(2, xpLabelOccurrences.Count); + foreach (var xpLabel in xpLabelOccurrences) Assert.Equal(0x40000000u, xpLabel.FontDid); - foreach (var xpValue in Flatten(tree!).Where(e => e.Id == CharacterStatController.XpNextValueId)) + + var xpValueOccurrences = Flatten(tree!).Where(e => e.Id == CharacterStatController.XpNextValueId).ToList(); + Assert.Equal(2, xpValueOccurrences.Count); + foreach (var xpValue in xpValueOccurrences) Assert.Equal(0x40000000u, xpValue.FontDid); } @@ -119,6 +136,13 @@ public sealed class CharacterPanelLiveDatTests }; foreach (ElementInfo listBox in listBoxes) { + // CT1 fix round: authored ListBox rect — CT5's row alignment + // and CT6's resize/scrollbar contract both depend on this. + Assert.Equal(0f, listBox.X); + Assert.Equal(112f, listBox.Y); + Assert.Equal(300f, listBox.Width); + Assert.Equal(160f, listBox.Height); + Assert.Equal(CharacterStatController.ListScrollbarId, listBox.ScrollbarElementId); Assert.Equal(5, listBox.TemplateList.Count); foreach (uint id in expected) @@ -128,6 +152,17 @@ public sealed class CharacterPanelLiveDatTests t => t.TemplateLayoutId == 0x21000045u && t.TemplateElementId == id); } } + + // CT1 fix round: the ListBox's own authored scrollbar rect — the + // "always reserved" gutter CT5/CT6 both depend on. + var scrollbars = Flatten(tree!).Where(e => e.Id == CharacterStatController.ListScrollbarId).ToList(); + Assert.Equal(2, scrollbars.Count); + foreach (ElementInfo scrollbar in scrollbars) + { + Assert.Equal(281f, scrollbar.X); + Assert.Equal(16f, scrollbar.Width); + Assert.Equal(160f, scrollbar.Height); + } } /// @@ -151,9 +186,13 @@ public sealed class CharacterPanelLiveDatTests Assert.Equal(20f, row.Height); Assert.Equal(0x06004CC2u, row.StateMedia["Normal"].File); // Row-template Highlight sprite (0x06000F93) — CharacterStatController's - // current RowHighlightSprite private constant is 0x06001397, a - // divergence flagged (not fixed) by CT1; see the research doc's - // "Corrections to the plan" §3. + // current RowHighlightSprite private constant is 0x06001397. CT1's + // fix round upgraded this from a flagged divergence to a SEALED + // VERDICT (gmAttributeUI::UpdateSelection SetState(6) -> + // InfoRegion::SetState on this exact template); 0x06001397 belongs + // to the spellbook row's separate selected-overlay mechanism. CT5 + // fixes RowHighlightSprite for the stat rows only; see the research + // doc's "Corrections to the plan" §3. Assert.Equal(0x06000F93u, row.StateMedia["Highlight"].File); ElementInfo icon = Assert.Single(row.Children, c => c.Id == 0x10000129u); From d38f71cb28b17364e8caf3448049229f6ef8b831 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 21:58:56 +0200 Subject: [PATCH 38/89] docs(CT): CT1 review-closed (fix round e264d839) Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-24-character-panel-parity-campaign.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index 223caa43..b36eb2d5 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -118,7 +118,7 @@ the composing function verbatim before writing a line of C#. ## Slices -**CT1 — DAT ground truth + pins. LANDED `ca4100e7` (2026-08-24).** +**CT1 — DAT ground truth + pins. REVIEW-CLOSED (2026-08-24): landed `ca4100e7`, Opus dual-lens review (1 doc-level blocker + 5 should-fix, all applied), fix round `e264d839`.** Research: `docs/research/2026-08-24-campaign-ct-dat-ground-truth.md`; 9 InstalledDat pins in `CharacterPanelLiveDatTests`. Three corrections now BINDING on later slices: From bcfddc97e7c1b09a3dd47d0012b188ae38d147de Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 22:03:22 +0200 Subject: [PATCH 39/89] =?UTF-8?q?feat(CT):=20CT2=20=E2=80=94=20Runtime=20c?= =?UTF-8?q?haracter-title=20ownership=20+=20wire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign CT slice CT2: the client now learns the character's earned titles and current display title from the server, owns that state in Runtime, and can send a display-title change. No UI (CT3/CT4). Wire (Core.Net): - GameEvents.ParseCharacterTitleTable (0x0029 CharacterTitle): retail CharacterTitleTable::UnPack @0x005c6e90 skips a leading u32 into no field — its own Pack @0x005c6e40 always writes the literal 1 there, matching ACE's unconditional Writer.Write(1u) — then reads displayTitleId, then a count-prefixed PList of earned ids. - GameEvents.ParseUpdateTitle (0x002B UpdateTitle): titleId + setAsDisplay, per CM_Social::DispatchUI_AddOrSetCharacterTitle @0x006a54c0 -> Handle_Social__AddOrSetCharacterTitle @0x00564260, which ALWAYS adds (SendNotice_AddCharacterTitle, unconditional) and additionally sets display only when setAsDisplay != 0 (SendNotice_SetDisplayCharacterTitle, gated). - SocialActions.BuildTitleSet / WorldSession.SendSetTitle: outbound TitleSet (0x002C), u32 titleId, matching ACE's GameActionSetTitle. - GameEventWiring gains onCharacterTitleTable/onUpdateTitle delegate holes (Core.Net cannot reference AcDream.Runtime directly). Runtime: - New RuntimeCharacterTitleState (RuntimeCharacterState.Titles): earned title id set + display title id, TableReplaced/TitleAdded/ DisplayTitleChanged events matching retail's unconditional-add / gated-display-set contract, clears at generation reset. RuntimeCharacterOwnershipSnapshot/CaptureOwnership/IsConverged and RuntimeCharacterSnapshot extended (trailing optional fields, no existing call site broken). - IRuntimeCharacterCommands.SetTitle: generation-gated, sends TitleSet only — NO optimistic local mutation. Verified against retail's own CM_Social::Event_SetDisplayCharacterTitle @0x006a5720, which sends the wire message and touches no local field; the display title updates only from the server's own echo (the CA-campaign lesson: never re-add an optimistic write). Implemented on both hosts (DirectGameRuntimeCommandAdapter direct-send; CurrentGameRuntimeCommandAdapter via LiveCommandBus / LiveSessionCommandRouter's new SetTitleRuntimeCmd). - LiveSessionEventRouter wires the two inbound events unconditionally (RuntimeCharacterState.Titles is a required child, not an optional sibling like Fellowship/Allegiance). App (non-UI plumbing + resolver): - CharacterTitleResolver (src/AcDream.App/UI/Layout/): ports CharacterTitleTable::GetCharacterTitleFromID @0x005c6ed0 — titleId -> EnumMapper(0x22000041) canonical key -> compute_str_hash -> StringTable(0x2300000E) localized text. Runtime stays id-only; CT3/ CT4 consume this for display. DIDs hardcoded per the RetailKeyNames precedent (CT1 verified them end-to-end). Register: no new row. Retail's send path is non-optimistic and so is ours — no deviation to record for this slice. Tests: wire conformance (byte-exact + truncation) in CharacterTitleEventsTests.cs + SocialActionsTests.cs; Runtime owner unit tests in RuntimeCharacterTitleStateTests.cs plus integration in RuntimeCharacterStateTests.cs; a no-local-mutation command test in DirectGameRuntimeCommandAdapterTests.cs; an InstalledDat pin (CharacterTitleResolverLiveDatTests.cs, ids 0/1/2/3/5/13/14, run green with ACDREAM_RUN_INSTALLED_DAT_TESTS=1). Full solution build green; hermetic filtered suite green (15,380 passed / 0 failed). Co-Authored-By: Claude Fable 5 --- ...6-08-24-character-panel-parity-campaign.md | 43 +++- .../Net/LiveSessionCommandRouter.cs | 7 + .../Net/LiveSessionRuntimeFactory.cs | 2 + .../CurrentGameRuntimeCommandAdapter.cs | 25 +++ .../UI/Layout/CharacterTitleResolver.cs | 85 ++++++++ src/AcDream.Core.Net/GameEventWiring.cs | 28 ++- src/AcDream.Core.Net/Messages/GameEvents.cs | 67 ++++++ .../Messages/SocialActions.cs | 21 ++ src/AcDream.Core.Net/WorldSession.cs | 12 ++ src/AcDream.Runtime/GameRuntimeCommands.cs | 13 ++ .../GameRuntimeGameplayViews.cs | 6 +- .../Gameplay/RuntimeCharacterState.cs | 154 +++++++++++++- .../DirectGameRuntimeCommandAdapter.cs | 27 +++ .../Session/LiveSessionEventRouter.cs | 11 +- .../InteractionUiRuntimeSourcesTests.cs | 5 + .../Net/LiveSessionCommandRouterTests.cs | 4 +- .../CharacterTitleResolverLiveDatTests.cs | 68 ++++++ .../Messages/CharacterTitleEventsTests.cs | 137 +++++++++++++ .../Messages/SocialActionsTests.cs | 27 +++ .../HeadlessCharacterOptionsSeederTests.cs | 6 + .../Gameplay/RuntimeCharacterStateTests.cs | 56 +++++ .../RuntimeCharacterTitleStateTests.cs | 193 ++++++++++++++++++ .../DirectGameRuntimeCommandAdapterTests.cs | 64 ++++++ 23 files changed, 1044 insertions(+), 17 deletions(-) create mode 100644 src/AcDream.App/UI/Layout/CharacterTitleResolver.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/CharacterTitleResolverLiveDatTests.cs create mode 100644 tests/AcDream.Core.Net.Tests/Messages/CharacterTitleEventsTests.cs create mode 100644 tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterTitleStateTests.cs diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index b36eb2d5..ca44648b 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -110,8 +110,9 @@ the composing function verbatim before writing a line of C#. 0x10000539`); pages currently show retail-authored closed visuals. - Header labels partially bound (`StatHeaderLine` + `PkStatus` seams exist in `CharacterStatController.Bind` — content contract wrong). -- `GameEventType.CharacterTitle/UpdateTitle` enum entries exist; no - parser, no state owner, no outbound builder. +- `GameEventType.CharacterTitle/UpdateTitle` enum entries exist and now + (CT2, landed) have a parser, a `RuntimeCharacterTitleState` owner, and + an outbound `TitleSet` builder — see CT2's paragraph below. - The character window registers with `DatConstraintSource` — authored min/max plumbing exists in `RetailWindowFrame`; Y-resize for this window and the list-scrollbar contract do not. @@ -145,13 +146,37 @@ rows), the value-column right margin, header element fonts/colors min/max constraints. Output: research doc + InstalledDat pins (the tooltip/scrollbar-pin pattern). No production changes. -**CT2 — Runtime title ownership + wire.** Parse `0x0029`/`0x002B`; -locate the DAT title-string table `GetCharacterTitleFromID` reads and -port the lookup; `RuntimeCharacterState` owns the title set + display -title (J4.3 owner; clears at generation reset); outbound `TitleSet` -builder behind a typed Runtime command; ordered change events for UI -and headless bots (#368 contract: hosts observe the same owner). -Conformance tests against ACE's writer shapes. +**CT2 — Runtime title ownership + wire. LANDED 2026-08-24.** Parsed +`0x0029 CharacterTitle` (retail's `CharacterTitleTable::UnPack +@0x005c6e90` — the leading ACE `1u`/retail-Pack-constant field is +discarded, matching retail's own read) and `0x002B UpdateTitle` +(`CM_Social::DispatchUI_AddOrSetCharacterTitle @0x006a54c0`: title id + +setAsDisplay). New sibling owner `RuntimeCharacterTitleState` +(`RuntimeCharacterState.Titles`) holds the earned-title set + display +title id, clears at generation reset (`CaptureOwnership`/`IsConverged` +extended with `TitleCount`/`DisplayTitleIsDefault`), and fires +`TableReplaced`/`TitleAdded`/`DisplayTitleChanged` — matching retail's +own unconditional-add / gated-display-set contract +(`Handle_Social__AddOrSetCharacterTitle @0x00564260`). Outbound +`TitleSet (0x002C)` ships behind `IRuntimeCharacterCommands.SetTitle` +on both hosts (`DirectGameRuntimeCommandAdapter` direct-send, +`CurrentGameRuntimeCommandAdapter` via the `LiveCommandBus`/ +`LiveSessionCommandRouter` queue) with **NO optimistic local +mutation** — verified against retail's own +`CM_Social::Event_SetDisplayCharacterTitle @0x006a5720`, which sends +the wire message and touches no local field; the display title updates +only from the server's own echo. No register row: this slice +introduces no retail deviation. App-layer `CharacterTitleResolver` +(`src/AcDream.App/UI/Layout/CharacterTitleResolver.cs`) ports +`GetCharacterTitleFromID`'s EnumMapper(`0x22000041`) → hash → +StringTable(`0x2300000E`) chain for CT3/CT4 to consume; Runtime stays +id-only. Conformance tests against ACE's writer shapes +(`tests/AcDream.Core.Net.Tests/Messages/CharacterTitleEventsTests.cs`), +Runtime owner tests (`RuntimeCharacterTitleStateTests.cs` + +`RuntimeCharacterStateTests.cs` integration), a no-local-mutation +command test (`DirectGameRuntimeCommandAdapterTests.cs`), and an +InstalledDat pin (`CharacterTitleResolverLiveDatTests.cs`, ids 0/1/2/3/ +5/13/14) all pass. **CT3 — Titles page UI.** Bind the authored page through the standard GUI classes (`UiTemplateListBox`/`UiScrollbar`/`UiButton` — zero diff --git a/src/AcDream.App/Net/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs index 9b7aa83a..c2969e76 100644 --- a/src/AcDream.App/Net/LiveSessionCommandRouter.cs +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -55,6 +55,10 @@ internal sealed record LiveSessionCommandBindings( // research §2.3-§2.7. No-ops when the batched module is clean, matching // retail's CPlayerModule::SaveToServer(force: 0). Action SaveCharacterOptions, + // Campaign CT slice CT2 (2026-08-24): TitleSet (0x002C) — sends only, no + // local mutation (RuntimeCharacterState.Titles updates from the + // server's own echo). + Action SendSetTitle, // Campaign FA slice FA2 (2026-08-12): fellowship + allegiance send // wrappers, the App-bus twin of DirectGameRuntimeCommandAdapter's // direct session.SendXxx calls. @@ -95,6 +99,7 @@ internal readonly record struct SetSingleCharacterOptionRuntimeCmd( uint OptionId, bool Value); internal readonly record struct SaveCharacterOptionsRuntimeCmd; +internal readonly record struct SetTitleRuntimeCmd(uint TitleId); internal readonly record struct AddFriendRuntimeCmd(string Name); internal readonly record struct RemoveFriendRuntimeCmd(uint CharacterId); @@ -228,6 +233,8 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting command.Value))); commands.Register( _ => SendIfActive(bindings.SaveCharacterOptions)); + commands.Register( + command => SendIfActive(() => bindings.SendSetTitle(command.TitleId))); commands.Register( command => SendIfActive(() => bindings.AddFriend(command.Name))); commands.Register( diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index fe3a19c0..9aa33819 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -770,6 +770,8 @@ internal sealed class LiveSessionRuntimeFactory CharacterState: _domain.Character, SendSingleCharacterOption: SendSingleCharacterOption, SaveCharacterOptions: SaveCharacterOptionsIfDirty, + // Campaign CT slice CT2 (2026-08-24). + SendSetTitle: session.SendSetTitle, // Campaign FA slice FA2 (2026-08-12): fellowship + allegiance send // wrappers, matching the WorldSession.SendXxx methods FA2 added. SendFellowshipCreate: session.SendFellowshipCreate, diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs index 7ec92c68..e7e8ca90 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs @@ -718,6 +718,31 @@ internal sealed class CurrentGameRuntimeCommandAdapter RuntimeCommandStatus.Accepted); } + public RuntimeCommandResult SetTitle( + RuntimeGenerationToken expectedGeneration, + uint titleId) + { + RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + if (titleId == 0u) + { + return EmitResult( + RuntimeCommandDomain.Character, + operation: 6, + RuntimeCommandStatus.Rejected); + } + // CT2: NO optimistic local mutation — matches + // DirectGameRuntimeCommandAdapter.SetTitle; RuntimeCharacterState. + // Titles updates only from the server's own echo. + _commands.Publish(new SetTitleRuntimeCmd(titleId)); + return EmitResult( + RuntimeCommandDomain.Character, + operation: 6, + RuntimeCommandStatus.Accepted, + titleId); + } + public RuntimeCommandResult Execute( RuntimeGenerationToken expectedGeneration, in RuntimeFriendCommand command) diff --git a/src/AcDream.App/UI/Layout/CharacterTitleResolver.cs b/src/AcDream.App/UI/Layout/CharacterTitleResolver.cs new file mode 100644 index 00000000..f967b288 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterTitleResolver.cs @@ -0,0 +1,85 @@ +using AcDream.Content; +using DatReaderWriter.DBObjs; + +namespace AcDream.App.UI.Layout; + +/// +/// Ports CharacterTitleTable::GetCharacterTitleFromID @0x005c6ed0's +/// title id -> display string chain: EnumMapper(0x22000041) +/// canonical key name -> compute_str_hash -> +/// StringTable(0x2300000E) localized text. +/// +/// +/// +/// Retail's chain is a two-level DBObj::GetDIDByEnum indirection +/// through a master map (DID 0x25000000): category 1 (EMAPPER) for +/// the EnumMapper, category 4 (STRINGTABLE) for the StringTable. Both +/// resolved DIDs are hardcoded here rather than porting the indirection +/// generically — the same precedent already +/// set for this exact category-4 family (its KeyNameTableId/ +/// MetaKeyNameTableId/DelimiterTableId); factor out a shared +/// helper only if a THIRD consumer of GetDIDByEnum appears. Both DIDs +/// were verified end-to-end against ACE's CharacterTitle.WarMage = 13 +/// in Campaign CT slice CT1 +/// (docs/research/2026-08-24-campaign-ct-dat-ground-truth.md §5, pinned by +/// CharacterPanelLiveDatTests.TitleStringTable_ResolvesWarMageEndToEnd). +/// +/// +/// stays +/// id-only (the #368 headless-bot-observes-the-same-owner contract); this +/// resolver is the App-layer seam CT3 (Titles page) and CT4 (header +/// identity line) consume for display strings. +/// +/// +public sealed class CharacterTitleResolver +{ + /// EnumMapper DID — title id -> canonical key name (e.g. + /// "ID_CharacterTitle_War_Mage"). + public const uint TitleEnumMapperId = 0x22000041u; + + /// StringTable DID — canonical key hash -> localized text. + public const uint TitleStringTableId = 0x2300000Eu; + + private readonly IDatReaderWriter _dats; + private readonly DatStringResolver _strings; + private EnumMapper? _titleEnumMapper; + private bool _loadedMapper; + + public CharacterTitleResolver(IDatReaderWriter dats) + { + _dats = dats ?? throw new ArgumentNullException(nameof(dats)); + _strings = new DatStringResolver(dats); + } + + /// + /// Resolves one title id to its localized display string. Returns null + /// for id 0 (retail's own early-return in + /// GetCharacterTitleFromID), an id absent from the EnumMapper, or + /// an unlocalized string — matching + /// 's own + /// null-on-miss contract. + /// + public string? Resolve(uint titleId) + { + if (titleId == 0u) + return null; + + if (!_loadedMapper) + { + _dats.Portal.TryGet(TitleEnumMapperId, out _titleEnumMapper); + _loadedMapper = true; + } + + if (_titleEnumMapper is null + || !_titleEnumMapper.IdToStringMap.TryGetValue(titleId, out var rawNameValue)) + { + return null; + } + + string rawName = rawNameValue.ToString(); + if (string.IsNullOrEmpty(rawName)) + return null; + + return _strings.Resolve(TitleStringTableId, DatStringResolver.ComputeHash(rawName)); + } +} diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 554008fc..3ce26e23 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -138,7 +138,15 @@ public static class GameEventWiring // in GameEventType since the wire catalog with nothing behind them, // so until this wiring the bytes arrived and were dropped. Action>? onContractTable = null, - Action? onContractUpdate = null) + Action? onContractUpdate = null, + // Campaign CT slice CT2 (2026-08-24): the title-table (0x0029) and + // add/set-display (0x002B) delegate holes. RuntimeCharacterState. + // Titles is an AcDream.Runtime type — Core.Net cannot reference + // AcDream.Runtime directly, so these are delegate holes exactly like + // every other Runtime-owned sink above. Optional/nullable so every + // existing caller compiles unchanged. + Action /*titleIds*/>? onCharacterTitleTable = null, + Action? onUpdateTitle = null) { ArgumentNullException.ThrowIfNull(dispatcher); ArgumentNullException.ThrowIfNull(items); @@ -474,6 +482,24 @@ public static class GameEventWiring }); } + // ── Character titles (Campaign CT slice CT2, 2026-08-24) ──────── + if (onCharacterTitleTable is not null) + { + registrar.Register(GameEventType.CharacterTitle, e => + { + var p = GameEvents.ParseCharacterTitleTable(e.Payload.Span); + if (p is not null) onCharacterTitleTable(p.Value.DisplayTitleId, p.Value.TitleIds); + }); + } + if (onUpdateTitle is not null) + { + registrar.Register(GameEventType.UpdateTitle, e => + { + var p = GameEvents.ParseUpdateTitle(e.Payload.Span); + if (p is not null) onUpdateTitle(p.Value.TitleId, p.Value.SetAsDisplay); + }); + } + if (onConfirmationRequest is not null) { registrar.Register(GameEventType.CharacterConfirmationRequest, e => diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs index 4226fb0b..e14ac19f 100644 --- a/src/AcDream.Core.Net/Messages/GameEvents.cs +++ b/src/AcDream.Core.Net/Messages/GameEvents.cs @@ -869,6 +869,73 @@ public static class GameEvents public static FellowshipFellowStatsDone ParseFellowshipFellowStatsDone(ReadOnlySpan payload) => new(payload.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(payload) : null); + // ── Character titles (Campaign CT slice CT2, 2026-08-24) ──────────────── + + /// + /// 0x0029 CharacterTitle — retail CharacterTitleTable::UnPack + /// @0x005c6e90 (named-retail pseudo-C offset 471514-471526). The + /// FIRST u32 is advanced past but never stored into any field — retail's + /// own CharacterTitleTable::Pack @0x005c6e40 (offset 471494-471510) + /// always writes the literal constant 1 there + /// (**(uint32_t**)arg2 = 1), and ACE's + /// GameEventCharacterTitle.cs matches with an unconditional + /// Writer.Write(1u) — a version/format tag retail itself discards + /// on read, not meaningful gameplay data (CT2 task item 1). Then the + /// current display title id (mDisplayTitle), then the + /// count-prefixed PList<uint> of every earned title id + /// (mTitleList). + /// + public readonly record struct CharacterTitleTable( + uint DisplayTitleId, + IReadOnlyList TitleIds); + + public static CharacterTitleTable? ParseCharacterTitleTable(ReadOnlySpan payload) + { + try + { + int pos = 0; + _ = FellowshipReadU32(payload, ref pos); // discarded pack-version tag — see doc comment above + uint displayTitleId = FellowshipReadU32(payload, ref pos); + uint count = FellowshipReadU32(payload, ref pos); + // PList::UnPack stores a 32-bit count bounded only by the + // remaining packet — same generous guard as + // SocialStateMessages.ParseFriendsUpdate. + if (count > 65_536) return null; + var titleIds = new uint[count]; + for (int i = 0; i < titleIds.Length; i++) + titleIds[i] = FellowshipReadU32(payload, ref pos); + return new CharacterTitleTable(displayTitleId, titleIds); + } + catch (FormatException) { return null; } + } + + /// + /// 0x002B UpdateTitle — retail's dispatch entry + /// CM_Social::DispatchUI_AddOrSetCharacterTitle @0x006a54c0 reads + /// exactly titleId then setAsDisplay and forwards to + /// ClientUISystem::Handle_Social__AddOrSetCharacterTitle + /// @0x00564260, which ALWAYS broadcasts + /// SendNotice_AddCharacterTitle(titleId) (a title just earned is + /// unconditionally added to the earned set) and, only when + /// setAsDisplay != 0, ALSO broadcasts + /// SendNotice_SetDisplayCharacterTitle(titleId). ACE's + /// GameEventUpdateTitle.cs: u32 title, u32 + /// setAsDisplayTitle — matches exactly. + /// + public readonly record struct UpdateTitle(uint TitleId, bool SetAsDisplay); + + public static UpdateTitle? ParseUpdateTitle(ReadOnlySpan payload) + { + try + { + int pos = 0; + uint titleId = FellowshipReadU32(payload, ref pos); + bool setAsDisplay = FellowshipReadU32(payload, ref pos) != 0u; + return new UpdateTitle(titleId, setAsDisplay); + } + catch (FormatException) { return null; } + } + private static FellowMember ReadFellow(ReadOnlySpan payload, ref int pos, uint guid) { uint cpCache = FellowshipReadU32(payload, ref pos); diff --git a/src/AcDream.Core.Net/Messages/SocialActions.cs b/src/AcDream.Core.Net/Messages/SocialActions.cs index 02ef46c8..2ca551e7 100644 --- a/src/AcDream.Core.Net/Messages/SocialActions.cs +++ b/src/AcDream.Core.Net/Messages/SocialActions.cs @@ -52,6 +52,16 @@ public static class SocialActions public const uint FellowshipAssignNewLeaderOpcode = 0x0290u; // u32 newLeaderGuid public const uint FellowshipChangeOpennessOpcode = 0x0291u; // u32 isOpen (0/1) — the REAL openness toggle + // Character titles (Campaign CT slice CT2, 2026-08-24). ACE + // GameActionType.TitleSet = 0x002C; GameActionSetTitle.cs reads exactly + // one u32 title id (session.Player.HandleActionSetTitle(title)). Retail + // sender CM_Social::Event_SetDisplayCharacterTitle @0x006a5720 (verified + // in the named pseudo-C): builds a 0x10-byte OrderHdr'd body whose + // payload is [u32 0x2c][u32 titleId] and sends via + // Proto_UI::SendToWeenie — no local state touched (see CT2's report: no + // optimistic mutation, matching this slice's CA-lesson design). + public const uint TitleSetOpcode = 0x002Cu; // u32 titleId + // Character options // CH3 (2026-08-09): the full-blob SetCharacterOptions (0x01A1) builder // and the string-payload AddChannel/RemoveChannel (0x0145/0x0146) @@ -230,6 +240,17 @@ public static class SocialActions return body; } + /// + /// Set the character's display title — 0x002C TitleSet. Retail's + /// Event_SetDisplayCharacterTitle sends this and touches NO local + /// state; the display title updates only when the server echoes back + /// UpdateTitle (0x002B) with setAsDisplay=true (CT2: no + /// optimistic local mutation, matching retail exactly — the CA-campaign + /// lesson never re-add one). + /// + public static byte[] BuildTitleSet(uint seq, uint titleId) + => SingleGuid(seq, TitleSetOpcode, titleId); + /// /// Toggle one character option and push it to the server. /// GameActionSetSingleCharacterOption @ GameActionType 0x0005 — diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 60d378fe..35f5888e 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -2719,6 +2719,18 @@ public sealed class WorldSession : IDisposable spellbookFilters)); } + /// + /// Send retail TitleSet (0x002C) — sets the character's display + /// title. Sends only; no local state changes here (Campaign CT slice + /// CT2: retail's own send path is non-optimistic — see + /// ). + /// + public void SendSetTitle(uint titleId) + { + uint seq = NextGameActionSequence(); + SendGameAction(SocialActions.BuildTitleSet(seq, titleId)); + } + public void SendAddFriend(string name) { uint seq = NextGameActionSequence(); diff --git a/src/AcDream.Runtime/GameRuntimeCommands.cs b/src/AcDream.Runtime/GameRuntimeCommands.cs index cef6e78d..d593910f 100644 --- a/src/AcDream.Runtime/GameRuntimeCommands.cs +++ b/src/AcDream.Runtime/GameRuntimeCommands.cs @@ -268,6 +268,19 @@ public interface IRuntimeCharacterCommands /// production call sites — Apply, logout — pass force = 0). /// RuntimeCommandResult SaveOptions(RuntimeGenerationToken expectedGeneration); + + /// + /// Retail TitleSet (GameActionType 0x002C) — sets the character's + /// display title (Campaign CT slice CT2, 2026-08-24; + /// CM_Social::Event_SetDisplayCharacterTitle @0x006a5720). Sends + /// only — no optimistic local mutation. The display title updates when + /// the server echoes UpdateTitle (0x002B) with + /// setAsDisplay=true, or the next CharacterTitle (0x0029) + /// table arrives, through RuntimeCharacterState.Titles. + /// + RuntimeCommandResult SetTitle( + RuntimeGenerationToken expectedGeneration, + uint titleId); } public enum RuntimeFriendCommandKind diff --git a/src/AcDream.Runtime/GameRuntimeGameplayViews.cs b/src/AcDream.Runtime/GameRuntimeGameplayViews.cs index ade50b95..e8277d1e 100644 --- a/src/AcDream.Runtime/GameRuntimeGameplayViews.cs +++ b/src/AcDream.Runtime/GameRuntimeGameplayViews.cs @@ -42,7 +42,11 @@ public readonly record struct RuntimeCharacterSnapshot( int ActiveEnchantmentCount, int DesiredComponentCount, int SkillCount, - uint SpellbookFilters); + uint SpellbookFilters, + // Campaign CT slice CT2 (2026-08-24): trailing/optional so every + // existing positional caller (GameRuntimeContractTests) compiles + // unchanged. + RuntimeCharacterTitleSnapshot Titles = default); public readonly record struct RuntimeVitalSnapshot( int Kind, diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs index 4e5a86fa..1fce941c 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs @@ -30,7 +30,18 @@ public readonly record struct RuntimeCharacterOwnershipSnapshot( /// still set — the ledger exists precisely to catch state a reset must /// clear but a value-only comparison would miss. /// - bool OptionsAreClean = true) + bool OptionsAreClean = true, + /// + /// Campaign CT slice CT2 (2026-08-24): the earned-title set's count — + /// zero when has never + /// received a table and after every reset. + /// + int TitleCount = 0, + /// + /// CT2: Titles.DisplayTitleId == 0 — the client-constructor + /// default (no display title id known yet). + /// + bool DisplayTitleIsDefault = true) { public bool IsConverged => IsDisposed @@ -47,7 +58,9 @@ public readonly record struct RuntimeCharacterOwnershipSnapshot( && OptionsAreDefaults && MovementSkillsAreReset && AutonomyIsDefault - && OptionsAreClean; + && OptionsAreClean + && TitleCount == 0 + && DisplayTitleIsDefault; } /// @@ -104,6 +117,7 @@ public sealed class RuntimeCharacterState : IDisposable LocalPlayer = new LocalPlayerState(Spellbook); Options = new RuntimeCharacterOptionsState(timeProvider); MovementSkills = new RuntimeMovementSkillState(); + Titles = new RuntimeCharacterTitleState(); View = new CharacterView(this); Spellbook.StateChanged += OnSpellbookChanged; Spellbook.EnchantmentsChanged += OnEnchantmentsChangedForMovement; @@ -117,6 +131,11 @@ public sealed class RuntimeCharacterState : IDisposable public LocalPlayerState LocalPlayer { get; } public RuntimeCharacterOptionsState Options { get; } public RuntimeMovementSkillState MovementSkills { get; } + /// Campaign CT slice CT2 (2026-08-24): earned titles + current + /// display title (retail's CharacterTitleTable). Id-only — + /// display-string resolution is an App-layer concern + /// (CharacterTitleResolver). + public RuntimeCharacterTitleState Titles { get; } public IRuntimeCharacterView View { get; } public bool IsDisposed => _disposed; @@ -232,7 +251,9 @@ public sealed class RuntimeCharacterState : IDisposable && _jumpSkillBase == -1 && _movementSkillAugmentations == default, AutonomyLevel == FullAutonomyLevel, - OptionsAreClean: !Options.IsDirty); + OptionsAreClean: !Options.IsDirty, + TitleCount: Titles.EarnedTitleIds.Count, + DisplayTitleIsDefault: Titles.DisplayTitleId == 0u); } /// @@ -445,6 +466,7 @@ public sealed class RuntimeCharacterState : IDisposable _movementSkillAugmentations = default; Volatile.Write(ref _autonomyLevel, FullAutonomyLevel); Try(MovementSkills.ResetSession, ref failures); + Try(Titles.ResetSession, ref failures); if (failures is not null) { throw new AggregateException( @@ -471,6 +493,7 @@ public sealed class RuntimeCharacterState : IDisposable _movementSkillAugmentations = default; Volatile.Write(ref _autonomyLevel, FullAutonomyLevel); Try(MovementSkills.ResetSession, ref failures); + Try(Titles.ResetSession, ref failures); } finally { @@ -526,7 +549,8 @@ public sealed class RuntimeCharacterState : IDisposable owner.Spellbook.ActiveEnchantments.Count(), owner.Spellbook.DesiredComponents.Count, owner.LocalPlayer.Skills.Count, - owner.Spellbook.SpellbookFilters); + owner.Spellbook.SpellbookFilters, + owner.Titles.Snapshot); public bool TryGetVital(int kind, out RuntimeVitalSnapshot vital) { @@ -1189,3 +1213,125 @@ public sealed class RuntimeMovementSkillState Interlocked.Increment(ref _revision); } } + +public readonly record struct RuntimeCharacterTitleSnapshot( + uint DisplayTitleId, + int TitleCount, + long Revision); + +/// +/// Campaign CT slice CT2 (2026-08-24): retail's CharacterTitleTable +/// (mDisplayTitle + mTitleList) ported into +/// 's existing options/movement-skill +/// sibling-owner shape. Two inbound wire events populate this: the full +/// table (0x0029 CharacterTitle, ) and the +/// incremental add/set-display notice (0x002B UpdateTitle, +/// ). Runtime stays id-only — display-string +/// resolution (EnumMapper -> hash -> StringTable, +/// CharacterTitleTable::GetCharacterTitleFromID @0x005c6ed0) is an +/// App-layer concern (AcDream.App.UI.Layout.CharacterTitleResolver), +/// matching this class's presentation-independent contract and the #368 +/// headless-bot-observes-the-same-owner rule. Outbound +/// TitleSet (0x002C) never mutates this state locally — retail's own +/// Event_SetDisplayCharacterTitle send path touches no local field; +/// the display title changes only when the server echoes back +/// UpdateTitle (never re-add an optimistic write here — the CA +/// campaign lesson). +/// +public sealed class RuntimeCharacterTitleState +{ + private readonly object _gate = new(); + private readonly HashSet _earnedTitleIds = new(); + private uint _displayTitleId; + private long _revision; + + /// Fires after a full 0x0029 CharacterTitle table replace. + public event Action? TableReplaced; + + /// + /// Fires once per 0x002B UpdateTitle arrival, UNCONDITIONALLY — + /// matches retail's own SendNotice_AddCharacterTitle, which + /// broadcasts regardless of whether the id was already in the earned + /// set. Carries the added title id. + /// + public event Action? TitleAdded; + + /// + /// Fires whenever the display title id changes — from either a fresh + /// 0x0029 table (a differing seed) or an 0x002B whose + /// setAsDisplay flag is set. Carries the NEW display title id. + /// + public event Action? DisplayTitleChanged; + + public uint DisplayTitleId => Volatile.Read(ref _displayTitleId); + public long Revision => Interlocked.Read(ref _revision); + + public IReadOnlyCollection EarnedTitleIds + { + get { lock (_gate) return _earnedTitleIds.ToArray(); } + } + + public bool HasEarnedTitle(uint titleId) + { + lock (_gate) return _earnedTitleIds.Contains(titleId); + } + + public RuntimeCharacterTitleSnapshot Snapshot + { + get + { + int count; + lock (_gate) count = _earnedTitleIds.Count; + return new RuntimeCharacterTitleSnapshot(DisplayTitleId, count, Revision); + } + } + + /// + /// 0x0029 CharacterTitle — a WHOLESALE authoritative replace + /// (retail's CharacterTitleTable::UnPack always rebuilds + /// mTitleList from scratch; there is no incremental-merge path + /// on this opcode). + /// + public void ReplaceTable(uint displayTitleId, IReadOnlyList titleIds) + { + ArgumentNullException.ThrowIfNull(titleIds); + lock (_gate) + { + _earnedTitleIds.Clear(); + foreach (uint id in titleIds) + _earnedTitleIds.Add(id); + } + bool displayChanged = DisplayTitleId != displayTitleId; + Volatile.Write(ref _displayTitleId, displayTitleId); + Interlocked.Increment(ref _revision); + TableReplaced?.Invoke(); + if (displayChanged) + DisplayTitleChanged?.Invoke(displayTitleId); + } + + /// + /// 0x002B UpdateTitle — retail's + /// ClientUISystem::Handle_Social__AddOrSetCharacterTitle: ALWAYS + /// add, and additionally set-display only when + /// is true. + /// + public void ApplyUpdateTitle(uint titleId, bool setAsDisplay) + { + lock (_gate) _earnedTitleIds.Add(titleId); + Interlocked.Increment(ref _revision); + TitleAdded?.Invoke(titleId); + if (setAsDisplay) + { + Volatile.Write(ref _displayTitleId, titleId); + Interlocked.Increment(ref _revision); + DisplayTitleChanged?.Invoke(titleId); + } + } + + public void ResetSession() + { + lock (_gate) _earnedTitleIds.Clear(); + Volatile.Write(ref _displayTitleId, 0u); + Interlocked.Increment(ref _revision); + } +} diff --git a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs index 3a9bcd10..42e3b18e 100644 --- a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs +++ b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs @@ -723,6 +723,33 @@ public sealed class DirectGameRuntimeCommandAdapter RuntimeCommandStatus.Accepted); } + public RuntimeCommandResult SetTitle( + RuntimeGenerationToken expectedGeneration, + uint titleId) + { + RuntimeCommandStatus gate = + Validate(expectedGeneration, out WorldSession? session); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + if (titleId == 0u) + { + return EmitResult( + RuntimeCommandDomain.Character, + operation: 6, + RuntimeCommandStatus.Rejected); + } + // CT2: NO optimistic local mutation — retail's own + // Event_SetDisplayCharacterTitle send path touches no local state; + // RuntimeCharacterState.Titles.DisplayTitleId updates only when the + // server echoes UpdateTitle (0x002B) with setAsDisplay=true. + session!.SendSetTitle(titleId); + return EmitResult( + RuntimeCommandDomain.Character, + operation: 6, + RuntimeCommandStatus.Accepted, + titleId); + } + public RuntimeCommandResult Execute( RuntimeGenerationToken expectedGeneration, in RuntimeFriendCommand command) diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index d4a15522..a0df61ed 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -345,7 +345,16 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting : null, onContractUpdate: social.Contracts is { } contractUpdate ? contractUpdate.ApplyUpdate - : null)); + : null, + // Campaign CT slice CT2 (2026-08-24): RuntimeCharacterState. + // Titles is a required child of the required `character. + // Character` owner (not an optional sibling like Fellowship/ + // Allegiance/Trade/House above), so these are wired + // unconditionally. + onCharacterTitleTable: (displayTitleId, titleIds) => + character.Character.Titles.ReplaceTable(displayTitleId, titleIds), + onUpdateTitle: (titleId, setAsDisplay) => + character.Character.Titles.ApplyUpdateTitle(titleId, setAsDisplay))); ConstructionCheckpoint(); // Campaign P Slice P1 (2026-07-30): burden recompute triggers — diff --git a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs index fe53d729..679b0210 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionUiRuntimeSourcesTests.cs @@ -436,6 +436,11 @@ public sealed class InteractionUiRuntimeSourcesTests RuntimeGenerationToken expectedGeneration) => Accepted(expectedGeneration); + public RuntimeCommandResult SetTitle( + RuntimeGenerationToken expectedGeneration, + uint titleId) => + Accepted(expectedGeneration, titleId); + private RuntimeCommandResult Accepted( RuntimeGenerationToken generation, uint objectId = 0u) diff --git a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs index 82b2db72..b02f7826 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionCommandRouterTests.cs @@ -728,7 +728,8 @@ public sealed class LiveSessionCommandRouterTests RuntimeCommunicationState? communication = null, RuntimeCharacterState? characterState = null, Action? sendSingleCharacterOption = null, - Action? saveCharacterOptions = null) => new( + Action? saveCharacterOptions = null, + Action? sendSetTitle = null) => new( new LiveSessionCommandBindings( clientBindings ?? NewClientBindings(), chat ?? new ChatLog(), @@ -767,6 +768,7 @@ public sealed class LiveSessionCommandRouterTests CharacterState: characterState ?? new RuntimeCharacterState(), SendSingleCharacterOption: sendSingleCharacterOption ?? ((_, _) => { }), SaveCharacterOptions: saveCharacterOptions ?? (() => { }), + SendSetTitle: sendSetTitle ?? (_ => { }), SendFellowshipCreate: (_, _) => { }, SendFellowshipRecruit: _ => { }, SendFellowshipDismiss: _ => { }, diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterTitleResolverLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterTitleResolverLiveDatTests.cs new file mode 100644 index 00000000..bbf3ff9a --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterTitleResolverLiveDatTests.cs @@ -0,0 +1,68 @@ +using AcDream.App.UI.Layout; +using AcDream.Content; +using DatReaderWriter; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign CT slice CT2 (2026-08-24) pin: +/// against the installed DAT set, end to end (EnumMapper 0x22000041 -> +/// compute_str_hash -> StringTable 0x2300000E). CT1's research doc +/// (docs/research/2026-08-24-campaign-ct-dat-ground-truth.md §5) verified +/// id 13 == "War Mage" against ACE's CharacterTitle.WarMage; this +/// pin adds several more low-ordinal ids from the same enum +/// (CharacterTitle.cs: Invalid=0, Adventurer=1, Archer=2, +/// Blademaster=3, LifeMage=5, Wayfarer=14) so a DAT revision or resolver +/// regression fails loudly instead of drifting unnoticed into CT3/CT4. +/// Follows the durable-pin pattern of . +/// +[Trait("Lane", "InstalledDat")] +public sealed class CharacterTitleResolverLiveDatTests +{ + private static string DatDirectory => + Path.Combine( + System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + + [InstalledDatFact] + public void Resolve_PinsSeveralTitleIdsToTheirRetailDisplayStrings() + { + using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + var resolver = new CharacterTitleResolver(new DatCollectionAdapter(dats)); + + // Retail's own early-return: id 0 (Invalid) never resolves. + Assert.Null(resolver.Resolve(0u)); + + Assert.Equal("Adventurer", resolver.Resolve(1u)); + Assert.Equal("Archer", resolver.Resolve(2u)); + Assert.Equal("Blademaster", resolver.Resolve(3u)); + Assert.Equal("Life Mage", resolver.Resolve(5u)); + Assert.Equal("War Mage", resolver.Resolve(13u)); + Assert.Equal("Wayfarer", resolver.Resolve(14u)); + } + + [InstalledDatFact] + public void Resolve_UnmappedId_ReturnsNull() + { + using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + var resolver = new CharacterTitleResolver(new DatCollectionAdapter(dats)); + + Assert.Null(resolver.Resolve(0xFFFFFFFEu)); + } + + [InstalledDatFact] + public void Resolve_CachesTheEnumMapperAcrossCalls() + { + // Not a durable behavioral pin — just confirms the lazy-load path + // used by the assertions above resolves the SAME value on a second + // call (the cached-mapper branch), not only on the first (cold) one. + using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + var resolver = new CharacterTitleResolver(new DatCollectionAdapter(dats)); + + string? first = resolver.Resolve(13u); + string? second = resolver.Resolve(13u); + + Assert.Equal("War Mage", first); + Assert.Equal(first, second); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/CharacterTitleEventsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CharacterTitleEventsTests.cs new file mode 100644 index 00000000..8d952178 --- /dev/null +++ b/tests/AcDream.Core.Net.Tests/Messages/CharacterTitleEventsTests.cs @@ -0,0 +1,137 @@ +using System; +using AcDream.Core.Net.Messages; +using Xunit; + +namespace AcDream.Core.Net.Tests.Messages; + +/// +/// Campaign CT slice CT2 (2026-08-24): golden-vector round-trip tests for +/// the two character-title S→C parsers added to . +/// Fixtures are built with (the ACE-mirror +/// writer) so a pass proves agreement with ACE's own +/// GameEventCharacterTitle.cs / GameEventUpdateTitle.cs +/// writer shapes, not just with itself. +/// +public sealed class CharacterTitleEventsTests +{ + // ── 0x0029 CharacterTitle ──────────────────────────────────────────── + + [Fact] + public void ParseCharacterTitleTable_RoundTrips_DiscardsLeadingVersionTag() + { + byte[] wire = new AceWireWriter() + .Write(1u) // ACE's literal pack-version tag — must be discarded + .Write(13u) // displayTitleId + .Write(3u) // count + .Write(1u) + .Write(5u) + .Write(13u) + .ToArray(); + + GameEvents.CharacterTitleTable? table = GameEvents.ParseCharacterTitleTable(wire); + + Assert.NotNull(table); + Assert.Equal(13u, table!.Value.DisplayTitleId); + Assert.Equal(new uint[] { 1u, 5u, 13u }, table.Value.TitleIds); + } + + [Fact] + public void ParseCharacterTitleTable_IgnoresNonOneLeadingTag() + { + // Retail's own UnPack never reads the leading dword into any field — + // it is advanced past unconditionally. A hostile/odd server value + // there must not change the parse outcome. + byte[] wire = new AceWireWriter() + .Write(0xDEADBEEFu) + .Write(7u) + .Write(0u) // empty title list + .ToArray(); + + GameEvents.CharacterTitleTable? table = GameEvents.ParseCharacterTitleTable(wire); + + Assert.NotNull(table); + Assert.Equal(7u, table!.Value.DisplayTitleId); + Assert.Empty(table.Value.TitleIds); + } + + [Fact] + public void ParseCharacterTitleTable_EmptyTitleList_RoundTrips() + { + byte[] wire = new AceWireWriter() + .Write(1u) + .Write(0u) // displayTitleId — no display title yet + .Write(0u) // count + .ToArray(); + + GameEvents.CharacterTitleTable? table = GameEvents.ParseCharacterTitleTable(wire); + + Assert.NotNull(table); + Assert.Equal(0u, table!.Value.DisplayTitleId); + Assert.Empty(table.Value.TitleIds); + } + + [Theory] + [InlineData(0)] // empty payload + [InlineData(4)] // only the leading tag + [InlineData(8)] // leading tag + displayTitleId, missing count + public void ParseCharacterTitleTable_TruncatedHeader_ReturnsNull(int length) + { + byte[] wire = new byte[length]; + Assert.Null(GameEvents.ParseCharacterTitleTable(wire)); + } + + [Fact] + public void ParseCharacterTitleTable_CountExceedsAvailableTitleIds_ReturnsNull() + { + byte[] wire = new AceWireWriter() + .Write(1u) + .Write(1u) + .Write(2u) // count says 2 title ids follow + .Write(1u) // only 1 is actually present + .ToArray(); + + Assert.Null(GameEvents.ParseCharacterTitleTable(wire)); + } + + // ── 0x002B UpdateTitle ─────────────────────────────────────────────── + + [Theory] + [InlineData(0u, false)] + [InlineData(13u, true)] + public void ParseUpdateTitle_RoundTrips(uint titleId, bool setAsDisplay) + { + byte[] wire = new AceWireWriter() + .Write(titleId) + .Write(setAsDisplay ? 1u : 0u) + .ToArray(); + + GameEvents.UpdateTitle? update = GameEvents.ParseUpdateTitle(wire); + + Assert.NotNull(update); + Assert.Equal(titleId, update!.Value.TitleId); + Assert.Equal(setAsDisplay, update.Value.SetAsDisplay); + } + + [Fact] + public void ParseUpdateTitle_NonZeroSetAsDisplay_IsTrue() + { + // ACE writes Convert.ToUInt32(bool) (always 0 or 1), but retail's own + // dispatch reads `!= 0`, not `== 1` — a non-1 truthy value must still + // resolve true. + byte[] wire = new AceWireWriter().Write(5u).Write(0xFFu).ToArray(); + + GameEvents.UpdateTitle? update = GameEvents.ParseUpdateTitle(wire); + + Assert.NotNull(update); + Assert.True(update!.Value.SetAsDisplay); + } + + [Theory] + [InlineData(0)] + [InlineData(4)] // only titleId, missing setAsDisplay + public void ParseUpdateTitle_Truncated_ReturnsNull(int length) + { + byte[] wire = new byte[length]; + Assert.Null(GameEvents.ParseUpdateTitle(wire)); + } +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs index a02d1928..02cfb284 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/SocialActionsTests.cs @@ -378,4 +378,31 @@ public sealed class SocialActionsTests Assert.Contains((0x68000002u, 3u), parsed.Value.DesiredComps); Assert.Contains((0x68000003u, 7u), parsed.Value.DesiredComps); } + + // ── Campaign CT slice CT2 (2026-08-24): TitleSet (0x002C) ──────────────── + + [Fact] + public void BuildTitleSet_GoldenByteVector() + { + byte[] body = SocialActions.BuildTitleSet(seq: 7, titleId: 13u); + + byte[] expected = + [ + 0xB1, 0xF7, 0x00, 0x00, // envelope 0xF7B1 + 0x07, 0x00, 0x00, 0x00, // seq 7 + 0x2C, 0x00, 0x00, 0x00, // opcode 0x002C TitleSet + 0x0D, 0x00, 0x00, 0x00, // titleId 13 + ]; + Assert.Equal(expected, body); + } + + [Fact] + public void BuildTitleSet_HasOpcodeAndTitleId() + { + byte[] body = SocialActions.BuildTitleSet(seq: 1, titleId: 0xBEEFu); + Assert.Equal(SocialActions.TitleSetOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); + Assert.Equal(0xBEEFu, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + } } diff --git a/tests/AcDream.Headless.Tests/HeadlessCharacterOptionsSeederTests.cs b/tests/AcDream.Headless.Tests/HeadlessCharacterOptionsSeederTests.cs index c0a1f4f5..46be2071 100644 --- a/tests/AcDream.Headless.Tests/HeadlessCharacterOptionsSeederTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessCharacterOptionsSeederTests.cs @@ -285,5 +285,11 @@ public sealed class HeadlessCharacterOptionsSeederTests RuntimeCommandStatus.Accepted, expectedGeneration); } + + public RuntimeCommandResult SetTitle( + RuntimeGenerationToken expectedGeneration, + uint titleId) => + throw new NotSupportedException( + "HeadlessCharacterOptionsSeeder never calls SetTitle."); } } diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs index b577b92b..414bef28 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterStateTests.cs @@ -3,6 +3,7 @@ using AcDream.Core.Net.Messages; using AcDream.Core.Properties; using AcDream.Core.Spells; using AcDream.Core.Player; +using AcDream.Runtime; using AcDream.Runtime.Gameplay; namespace AcDream.Runtime.Tests.Gameplay; @@ -1051,6 +1052,61 @@ public sealed class RuntimeCharacterStateTests Assert.True(state.CaptureOwnership().AutonomyIsDefault); } + // ── Campaign CT slice CT2 (2026-08-24): Titles owner integration ───── + + [Fact] + public void Titles_IsOwnedAsASiblingOfOptionsAndMovementSkills() + { + using var state = new RuntimeCharacterState(); + + state.Titles.ReplaceTable(13u, [1u, 5u, 13u]); + + Assert.Equal(13u, state.Titles.DisplayTitleId); + Assert.Equal(3, state.Titles.EarnedTitleIds.Count); + Assert.Equal(3, state.CaptureOwnership().TitleCount); + Assert.False(state.CaptureOwnership().DisplayTitleIsDefault); + } + + [Fact] + public void ResetSession_ClearsTitles() + { + using var state = new RuntimeCharacterState(); + state.Titles.ReplaceTable(13u, [1u, 5u, 13u]); + + state.ResetSession(); + + Assert.Equal(0u, state.Titles.DisplayTitleId); + Assert.Empty(state.Titles.EarnedTitleIds); + Assert.True(state.CaptureOwnership().TitleCount == 0); + Assert.True(state.CaptureOwnership().DisplayTitleIsDefault); + Assert.True(state.CaptureOwnership().IsConverged is false); // IsDisposed still false + } + + [Fact] + public void Dispose_ClearsTitlesAndConverges() + { + var state = new RuntimeCharacterState(); + state.Titles.ReplaceTable(13u, [1u, 5u, 13u]); + + state.Dispose(); + + Assert.Equal(0u, state.Titles.DisplayTitleId); + Assert.Empty(state.Titles.EarnedTitleIds); + Assert.True(state.CaptureOwnership().IsConverged); + } + + [Fact] + public void CharacterSnapshot_EmbedsTitlesSnapshot() + { + using var state = new RuntimeCharacterState(); + state.Titles.ReplaceTable(13u, [1u, 5u, 13u]); + + RuntimeCharacterSnapshot snapshot = state.View.Snapshot; + + Assert.Equal(13u, snapshot.Titles.DisplayTitleId); + Assert.Equal(3, snapshot.Titles.TitleCount); + } + private static ActiveEnchantmentRecord MakeVitae(uint spellId, float val) => new( spellId, LayerId: 0u, Duration: -1f, CasterGuid: 0u, diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterTitleStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterTitleStateTests.cs new file mode 100644 index 00000000..0aa1f1a2 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterTitleStateTests.cs @@ -0,0 +1,193 @@ +using AcDream.Runtime.Gameplay; + +namespace AcDream.Runtime.Tests.Gameplay; + +/// +/// Campaign CT slice CT2 (2026-08-24): unit tests for +/// — the retail +/// CharacterTitleTable port. Covers the full table replace +/// (0x0029 CharacterTitle), the incremental add/set-display notice +/// (0x002B UpdateTitle), the retail-verified unconditional-add / +/// gated-display-set contract, and generation-reset clearing. +/// +public sealed class RuntimeCharacterTitleStateTests +{ + [Fact] + public void InitialState_IsEmptyWithDefaultDisplayTitle() + { + var titles = new RuntimeCharacterTitleState(); + + Assert.Equal(0u, titles.DisplayTitleId); + Assert.Empty(titles.EarnedTitleIds); + Assert.False(titles.HasEarnedTitle(13u)); + } + + [Fact] + public void ReplaceTable_SetsEarnedIdsAndDisplayTitle_FiresTableReplaced() + { + var titles = new RuntimeCharacterTitleState(); + int tableReplacedCount = 0; + titles.TableReplaced += () => tableReplacedCount++; + + titles.ReplaceTable(13u, [1u, 5u, 13u]); + + Assert.Equal(13u, titles.DisplayTitleId); + Assert.Equal(new HashSet { 1u, 5u, 13u }, titles.EarnedTitleIds.ToHashSet()); + Assert.True(titles.HasEarnedTitle(5u)); + Assert.False(titles.HasEarnedTitle(99u)); + Assert.Equal(1, tableReplacedCount); + } + + [Fact] + public void ReplaceTable_IsAWholesaleReplace_DropsIdsMissingFromTheNewTable() + { + var titles = new RuntimeCharacterTitleState(); + titles.ReplaceTable(1u, [1u, 2u, 3u]); + + titles.ReplaceTable(1u, [1u]); + + Assert.Equal(new uint[] { 1u }, titles.EarnedTitleIds.ToArray()); + Assert.False(titles.HasEarnedTitle(2u)); + Assert.False(titles.HasEarnedTitle(3u)); + } + + [Fact] + public void ReplaceTable_DisplayTitleIdUnchanged_DoesNotFireDisplayTitleChanged() + { + var titles = new RuntimeCharacterTitleState(); + titles.ReplaceTable(13u, [13u]); + var fired = new List(); + titles.DisplayTitleChanged += id => fired.Add(id); + + titles.ReplaceTable(13u, [13u, 14u]); + + Assert.Empty(fired); + Assert.Equal(13u, titles.DisplayTitleId); + } + + [Fact] + public void ReplaceTable_DisplayTitleIdChanges_FiresDisplayTitleChangedWithNewId() + { + var titles = new RuntimeCharacterTitleState(); + titles.ReplaceTable(13u, [13u, 14u]); + var fired = new List(); + titles.DisplayTitleChanged += id => fired.Add(id); + + titles.ReplaceTable(14u, [13u, 14u]); + + Assert.Equal([14u], fired); + Assert.Equal(14u, titles.DisplayTitleId); + } + + // ── 0x002B UpdateTitle: unconditional add, gated display-set ───────── + // Retail's Handle_Social__AddOrSetCharacterTitle @0x00564260 ALWAYS + // calls SendNotice_AddCharacterTitle, and additionally calls + // SendNotice_SetDisplayCharacterTitle only when setAsDisplay != 0. + + [Fact] + public void ApplyUpdateTitle_AlwaysAddsAndFiresTitleAdded_EvenWhenNotSetAsDisplay() + { + var titles = new RuntimeCharacterTitleState(); + var added = new List(); + titles.TitleAdded += id => added.Add(id); + var displayChanged = new List(); + titles.DisplayTitleChanged += id => displayChanged.Add(id); + + titles.ApplyUpdateTitle(7u, setAsDisplay: false); + + Assert.True(titles.HasEarnedTitle(7u)); + Assert.Equal([7u], added); + Assert.Empty(displayChanged); + Assert.Equal(0u, titles.DisplayTitleId); + } + + [Fact] + public void ApplyUpdateTitle_SetAsDisplayTrue_AddsAndUpdatesDisplayTitle() + { + var titles = new RuntimeCharacterTitleState(); + var added = new List(); + titles.TitleAdded += id => added.Add(id); + var displayChanged = new List(); + titles.DisplayTitleChanged += id => displayChanged.Add(id); + + titles.ApplyUpdateTitle(13u, setAsDisplay: true); + + Assert.True(titles.HasEarnedTitle(13u)); + Assert.Equal([13u], added); + Assert.Equal([13u], displayChanged); + Assert.Equal(13u, titles.DisplayTitleId); + } + + [Fact] + public void ApplyUpdateTitle_AlreadyEarnedId_StillFiresTitleAddedUnconditionally() + { + // Retail's own broadcast is unconditional — it does not check + // membership before firing the notice. + var titles = new RuntimeCharacterTitleState(); + titles.ApplyUpdateTitle(7u, setAsDisplay: false); + var added = new List(); + titles.TitleAdded += id => added.Add(id); + + titles.ApplyUpdateTitle(7u, setAsDisplay: false); + + Assert.Equal([7u], added); + Assert.Single(titles.EarnedTitleIds); + } + + [Fact] + public void ApplyUpdateTitle_SetAsDisplayOnAnUnearnedId_AddsItAndSetsDisplay() + { + // Retail sends UpdateTitle for a title the player just earned — the + // id is not necessarily already in the earned set beforehand. + var titles = new RuntimeCharacterTitleState(); + + titles.ApplyUpdateTitle(99u, setAsDisplay: true); + + Assert.True(titles.HasEarnedTitle(99u)); + Assert.Equal(99u, titles.DisplayTitleId); + } + + [Fact] + public void ResetSession_ClearsEarnedIdsAndDisplayTitle() + { + var titles = new RuntimeCharacterTitleState(); + titles.ReplaceTable(13u, [1u, 5u, 13u]); + + titles.ResetSession(); + + Assert.Equal(0u, titles.DisplayTitleId); + Assert.Empty(titles.EarnedTitleIds); + } + + [Fact] + public void ResetSession_BumpsRevisionEvenWhenAlreadyEmpty() + { + var titles = new RuntimeCharacterTitleState(); + long before = titles.Revision; + + titles.ResetSession(); + + Assert.True(titles.Revision > before); + } + + [Fact] + public void Snapshot_ReflectsDisplayTitleAndCount() + { + var titles = new RuntimeCharacterTitleState(); + titles.ReplaceTable(13u, [1u, 5u, 13u]); + + RuntimeCharacterTitleSnapshot snapshot = titles.Snapshot; + + Assert.Equal(13u, snapshot.DisplayTitleId); + Assert.Equal(3, snapshot.TitleCount); + Assert.Equal(titles.Revision, snapshot.Revision); + } + + [Fact] + public void ReplaceTable_NullTitleIds_Throws() + { + var titles = new RuntimeCharacterTitleState(); + Assert.Throws( + () => titles.ReplaceTable(1u, null!)); + } +} diff --git a/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs b/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs index c2f4fe6d..e61390e0 100644 --- a/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs @@ -1,3 +1,4 @@ +using System.Buffers.Binary; using System.Net; using AcDream.Core.Combat; using AcDream.Core.Items; @@ -345,6 +346,69 @@ public sealed class DirectGameRuntimeCommandAdapterTests runtime.Dispose(); } + // ── Campaign CT slice CT2 (2026-08-24): SetTitle (TitleSet 0x002C) ─── + + [Fact] + public void SetTitle_SendsTheWireActionWithoutAnyLocalMutation() + { + (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = + CreateStartedHarness(); + var gameActions = new List(); + operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); + + RuntimeCommandResult result = adapter.Character.SetTitle( + runtime.Generation, + titleId: 13u); + + Assert.True(result.Accepted); + Assert.Single(gameActions); + byte[] sent = gameActions[0]; + Assert.Equal( + SocialActions.TitleSetOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(sent.AsSpan(8))); + Assert.Equal(13u, BinaryPrimitives.ReadUInt32LittleEndian(sent.AsSpan(12))); + + // CA-lesson: NO optimistic local mutation. RuntimeCharacterState. + // Titles only updates from the server's own echo (0x002B/0x0029). + Assert.Equal(0u, runtime.CharacterOwner.Titles.DisplayTitleId); + Assert.Empty(runtime.CharacterOwner.Titles.EarnedTitleIds); + runtime.Dispose(); + } + + [Fact] + public void SetTitle_ZeroTitleId_RejectsWithoutSendingAnything() + { + (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = + CreateStartedHarness(); + var gameActions = new List(); + operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); + + RuntimeCommandResult result = adapter.Character.SetTitle( + runtime.Generation, + titleId: 0u); + + Assert.Equal(RuntimeCommandStatus.Rejected, result.Status); + Assert.Empty(gameActions); + runtime.Dispose(); + } + + [Fact] + public void SetTitle_StaleGeneration_RejectsWithoutSendingAnything() + { + (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = + CreateStartedHarness(); + var gameActions = new List(); + operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body); + RuntimeGenerationToken stale = runtime.Generation; + _ = adapter.Session.Reconnect(runtime.Generation); + + RuntimeCommandResult result = adapter.Character.SetTitle(stale, titleId: 13u); + + Assert.Equal(RuntimeCommandStatus.StaleGeneration, result.Status); + Assert.Empty(gameActions); + runtime.Dispose(); + } + [Fact] public void SaveOptions_FlushesTheDirtyBlobThenNoOpsWhenClean() { From 544f8cb2d7396d15f2d667b4fb7d4fc3e1ebe379 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 22:27:07 +0200 Subject: [PATCH 40/89] =?UTF-8?q?fix(CT):=20CT2=20fix=20round=20=E2=80=94?= =?UTF-8?q?=20dedupe=20client-side=20title=20add,=20drop=20retail-inexact?= =?UTF-8?q?=20send=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus dual-lens review of CT2 (bcfddc97) found 4 SHOULD-FIX + notes; this applies the campaign lead's rulings. F1 (the important one): retail's client-side table add is DEDUPED — gmCharacterTitleUI::RecvNotice_AddCharacterTitle @0x0049a990 walks mTitleList and returns without effect when the id is already present, only inserting on a miss. The server-side SendNotice_AddCharacterTitle broadcast is unconditional, but RuntimeCharacterTitleState.ApplyUpdateTitle models the CLIENT receive side, so TitleAdded now fires only on a genuine new membership. Inverted the pin: ApplyUpdateTitle_AlreadyEarnedId_DoesNotFireTitleAddedOrBumpRevision. F3: removed the send-side titleId==0 rejection from both command adapters. Retail's own send path (Event_SetDisplayCharacterTitle @0x006a5720) packs whatever id it is handed, and ACE accepts id 0 (CharacterTitle.Invalid is a defined enum value) — retail's real protection is the UI ghost-when-current gate (CT3's job), not a send-side rejection. No register row: this makes acdream MORE retail-exact. A2: ResetSession now publishes TableReplaced unconditionally and DisplayTitleChanged when the display id was non-zero before the clear, matching the LocalPlayerState.Clear() precedent (publish every category even when Clear is repeated, so a failed reset can converge on retry). A3: RuntimeCharacterState.CaptureOwnership reads the new non-allocating Titles.Count instead of EarnedTitleIds.Count; EarnedTitleIds now carries an XML warning that every read allocates. A4/A5: ReplaceTable/ApplyUpdateTitle now mutate under one _gate hold with change flags computed inside the lock and events raised after release; every revision bump is gated on an actual state change (a no-op wire resend produces zero revision edges), matching the change-gated RuntimeMovementSkillState precedent. TableReplaced itself still fires unconditionally per retail's own Refresh() dispatch on 0x0029. A1/A6/A7/A8: CharacterTitleResolverLiveDatTests honors ACDREAM_DAT_DIR first (CT1 fix-round pattern); documented the EmitResult primaryObjectId-as-title-id precedent inline; corrected the "third consumer" comment (CT1 §5 already records gmAttributeUI::PostInit's icon-DID lookup — CT5 factors the shared GetDIDByEnum helper); added a titleId -> resolved-string memo to CharacterTitleResolver, the DAT-static equivalent of retail's lazy-hash cache on the string buffer. Appended a "CT3 anchors from the CT2 review" list to the plan doc's CT2 ledger entry for CT3 to consume. Build green. Runtime (102), Core.Net (12), and App (27 + 3 InstalledDat pins under ACDREAM_RUN_INSTALLED_DAT_TESTS=1) title-scoped tests pass. Full hermetic solution suite (Lane exclusions per the release gate) is green: 0 failures across all 15 test projects. Co-Authored-By: Claude Fable 5 --- ...6-08-24-character-panel-parity-campaign.md | 71 ++++++++-- .../CurrentGameRuntimeCommandAdapter.cs | 19 ++- .../UI/Layout/CharacterTitleResolver.cs | 43 ++++-- .../Gameplay/RuntimeCharacterState.cs | 130 ++++++++++++++---- .../DirectGameRuntimeCommandAdapter.cs | 18 ++- .../CharacterTitleResolverLiveDatTests.cs | 3 +- .../RuntimeCharacterTitleStateTests.cs | 118 +++++++++++++++- .../DirectGameRuntimeCommandAdapterTests.cs | 17 ++- 8 files changed, 350 insertions(+), 69 deletions(-) diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index ca44648b..22e7524b 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -155,14 +155,11 @@ setAsDisplay). New sibling owner `RuntimeCharacterTitleState` (`RuntimeCharacterState.Titles`) holds the earned-title set + display title id, clears at generation reset (`CaptureOwnership`/`IsConverged` extended with `TitleCount`/`DisplayTitleIsDefault`), and fires -`TableReplaced`/`TitleAdded`/`DisplayTitleChanged` — matching retail's -own unconditional-add / gated-display-set contract -(`Handle_Social__AddOrSetCharacterTitle @0x00564260`). Outbound +`TableReplaced`/`TitleAdded`/`DisplayTitleChanged`. Outbound `TitleSet (0x002C)` ships behind `IRuntimeCharacterCommands.SetTitle` on both hosts (`DirectGameRuntimeCommandAdapter` direct-send, `CurrentGameRuntimeCommandAdapter` via the `LiveCommandBus`/ -`LiveSessionCommandRouter` queue) with **NO optimistic local -mutation** — verified against retail's own +`LiveSessionCommandRouter` queue) — verified against retail's own `CM_Social::Event_SetDisplayCharacterTitle @0x006a5720`, which sends the wire message and touches no local field; the display title updates only from the server's own echo. No register row: this slice @@ -173,10 +170,66 @@ StringTable(`0x2300000E`) chain for CT3/CT4 to consume; Runtime stays id-only. Conformance tests against ACE's writer shapes (`tests/AcDream.Core.Net.Tests/Messages/CharacterTitleEventsTests.cs`), Runtime owner tests (`RuntimeCharacterTitleStateTests.cs` + -`RuntimeCharacterStateTests.cs` integration), a no-local-mutation -command test (`DirectGameRuntimeCommandAdapterTests.cs`), and an -InstalledDat pin (`CharacterTitleResolverLiveDatTests.cs`, ids 0/1/2/3/ -5/13/14) all pass. +`RuntimeCharacterStateTests.cs` integration), a wire-send command test +(`DirectGameRuntimeCommandAdapterTests.cs`), and an InstalledDat pin +(`CharacterTitleResolverLiveDatTests.cs`, ids 0/1/2/3/5/13/14) all pass. + +**CT2 fix round (Opus dual-lens review, 2026-08-24).** Four SHOULD-FIX +corrections landed. **F1 (the important one):** the NOTICE broadcast is +unconditional (retail's server-side `SendNotice_AddCharacterTitle` fires +regardless of prior membership), but the client-side table ADD is +DEDUPED — `gmCharacterTitleUI::RecvNotice_AddCharacterTitle @0x0049a990` +walks `mTitleList` and returns without effect when the id is already +present, only inserting + adding the row on a miss. +`RuntimeCharacterTitleState.ApplyUpdateTitle` (which models the CLIENT +receive side, not the server send side) now fires `TitleAdded` only on a +genuine new membership; the inverted pin is +`ApplyUpdateTitle_AlreadyEarnedId_DoesNotFireTitleAddedOrBumpRevision`. +**F3:** the send-side `titleId == 0` rejection is REMOVED from both +command adapters — retail's own send path +(`Event_SetDisplayCharacterTitle @0x006a5720`) packs whatever id it is +handed, and ACE accepts id 0 (`CharacterTitle.Invalid` is a defined enum +value); retail's actual protection is the UI ghost-when-current gate +(CT3's job), not a send-side rejection. No register row: removing the +guard makes acdream MORE retail-exact, not less. The fix round also +closed four SHOULD-FIX-adjacent items: A2 (`ResetSession` now publishes +`TableReplaced` unconditionally and `DisplayTitleChanged` when the +display id was non-zero before the clear, matching the +`LocalPlayerState.Clear()` precedent), A3 (`RuntimeCharacterState +.CaptureOwnership` reads the new non-allocating `Titles.Count` instead of +`EarnedTitleIds.Count`), A4 (the whole mutation in `ReplaceTable`/ +`ApplyUpdateTitle` now happens under one `_gate` hold, with change flags +computed inside the lock and events raised after release), and A5 (every +revision bump is now gated on an actual state change — a no-op wire +resend produces zero revision edges; `TableReplaced` itself still fires +unconditionally per retail's own `Refresh()` dispatch). A1 +(`CharacterTitleResolverLiveDatTests` now honors `ACDREAM_DAT_DIR` +first), A6 (documented the `EmitResult` `primaryObjectId`-as-title-id +precedent inline), A7 (corrected the "third consumer" comment — CT1 §5 +already records `gmAttributeUI::PostInit`'s icon-DID lookup as that third +consumer; CT5 is where the shared `GetDIDByEnum` helper gets factored), +and A8 (`CharacterTitleResolver` now memoizes the final resolved string +per title id, the DAT-static equivalent of retail's lazy-hash cache on +the string buffer) round out the fix round. + +**CT3 anchors from the CT2 review** (carried forward for CT3 to consume, +not yet acted on): +1. CT3 must refresh the display-title TEXT from `TableReplaced` as well + as `DisplayTitleChanged` — retail's + `RecvNotice_UpdateCharacterTitleTable` unconditionally `Refresh()`es + on every `0x0029` arrival, not only when the display id differs. +2. ACE sends NO echo when re-setting the already-current title — the + Set-as-Display button must not wait for a confirmation that never + arrives; retail prevents the send in the first place via the UI + ghost-when-current gate. +3. Retail's fallback display text when a title id doesn't resolve is the + hardcoded literal `"Unknown"` (`Refresh @0x0049abc0`), not a + StringTable key — `CharacterTitleResolver.Resolve` returning `null` + is the correct signal for CT3 to substitute that literal. +4. The deduped client-side add contract (F1 above) — CT3's title-list + row rendering must not assume every `TitleAdded` firing corresponds + to a wire arrival; the reverse still holds (every genuine new row has + a `TitleAdded` firing). **CT3 — Titles page UI.** Bind the authored page through the standard GUI classes (`UiTemplateListBox`/`UiScrollbar`/`UiButton` — zero diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs index e7e8ca90..dd8c811b 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs @@ -725,17 +725,22 @@ internal sealed class CurrentGameRuntimeCommandAdapter RuntimeCommandStatus gate = Validate(expectedGeneration, requireWorld: true); if (gate != RuntimeCommandStatus.Accepted) return Result(gate); - if (titleId == 0u) - { - return EmitResult( - RuntimeCommandDomain.Character, - operation: 6, - RuntimeCommandStatus.Rejected); - } + // F3 (CT2 fix round, 2026-08-24): NO id-0 guard here — retail's own + // send path (Event_SetDisplayCharacterTitle @0x006a5720) packs + // whatever id it is handed, and ACE accepts id 0 + // (CharacterTitle.Invalid is a defined enum value). Retail's + // protection against sending an unearned/invalid id is the UI + // ghost-when-current gate (CT3's job), not a send-side rejection — + // a client-side guard here blocks a state the server honors. // CT2: NO optimistic local mutation — matches // DirectGameRuntimeCommandAdapter.SetTitle; RuntimeCharacterState. // Titles updates only from the server's own echo. _commands.Publish(new SetTitleRuntimeCmd(titleId)); + // A6 (CT2 fix round): titleId rides the EmitResult objectId slot — + // same in-class precedent as Advance's command.StatId above (see + // the S4 history comment on SaveOptions/EmitResult, which + // established this field as a typed domain-payload-id slot, not + // always an object guid). return EmitResult( RuntimeCommandDomain.Character, operation: 6, diff --git a/src/AcDream.App/UI/Layout/CharacterTitleResolver.cs b/src/AcDream.App/UI/Layout/CharacterTitleResolver.cs index f967b288..6bea0f71 100644 --- a/src/AcDream.App/UI/Layout/CharacterTitleResolver.cs +++ b/src/AcDream.App/UI/Layout/CharacterTitleResolver.cs @@ -17,11 +17,16 @@ namespace AcDream.App.UI.Layout; /// resolved DIDs are hardcoded here rather than porting the indirection /// generically — the same precedent already /// set for this exact category-4 family (its KeyNameTableId/ -/// MetaKeyNameTableId/DelimiterTableId); factor out a shared -/// helper only if a THIRD consumer of GetDIDByEnum appears. Both DIDs -/// were verified end-to-end against ACE's CharacterTitle.WarMage = 13 -/// in Campaign CT slice CT1 -/// (docs/research/2026-08-24-campaign-ct-dat-ground-truth.md §5, pinned by +/// MetaKeyNameTableId/DelimiterTableId). A THIRD consumer of +/// GetDIDByEnum has already appeared — +/// gmAttributeUI::PostInit @0x0049DB70 resolves per-attribute icon +/// DIDs via category 0x10000002 — so CT5 (not "if a third consumer +/// appears") is where the shared GetDIDByEnum(enumValue, category) +/// helper gets factored out, per +/// docs/research/2026-08-24-campaign-ct-dat-ground-truth.md §5 item 1. +/// Both DIDs used here were verified end-to-end against ACE's +/// CharacterTitle.WarMage = 13 in Campaign CT slice CT1 (same §5, +/// pinned by /// CharacterPanelLiveDatTests.TitleStringTable_ResolvesWarMageEndToEnd). /// /// @@ -42,6 +47,7 @@ public sealed class CharacterTitleResolver private readonly IDatReaderWriter _dats; private readonly DatStringResolver _strings; + private readonly Dictionary _resolvedCache = new(); private EnumMapper? _titleEnumMapper; private bool _loadedMapper; @@ -59,27 +65,38 @@ public sealed class CharacterTitleResolver /// 's own /// null-on-miss contract. /// + /// + /// A8 (CT2 fix round, 2026-08-24): memoizes the final resolved string + /// per (including misses). Retail caches the + /// computed hash directly on the string buffer + /// (GetCharacterTitleFromID's 0xFFFFFFFF lazy-hash + /// sentinel); a memo of the final string is the equivalent here given + /// our immutable, DAT-static tables — no invalidation is needed. + /// public string? Resolve(uint titleId) { if (titleId == 0u) return null; + if (_resolvedCache.TryGetValue(titleId, out string? cached)) + return cached; + if (!_loadedMapper) { _dats.Portal.TryGet(TitleEnumMapperId, out _titleEnumMapper); _loadedMapper = true; } - if (_titleEnumMapper is null - || !_titleEnumMapper.IdToStringMap.TryGetValue(titleId, out var rawNameValue)) + string? resolved = null; + if (_titleEnumMapper is not null + && _titleEnumMapper.IdToStringMap.TryGetValue(titleId, out var rawNameValue)) { - return null; + string rawName = rawNameValue.ToString(); + if (!string.IsNullOrEmpty(rawName)) + resolved = _strings.Resolve(TitleStringTableId, DatStringResolver.ComputeHash(rawName)); } - string rawName = rawNameValue.ToString(); - if (string.IsNullOrEmpty(rawName)) - return null; - - return _strings.Resolve(TitleStringTableId, DatStringResolver.ComputeHash(rawName)); + _resolvedCache[titleId] = resolved; + return resolved; } } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs index 1fce941c..a32aff5c 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCharacterState.cs @@ -252,7 +252,7 @@ public sealed class RuntimeCharacterState : IDisposable && _movementSkillAugmentations == default, AutonomyLevel == FullAutonomyLevel, OptionsAreClean: !Options.IsDirty, - TitleCount: Titles.EarnedTitleIds.Count, + TitleCount: Titles.Count, DisplayTitleIsDefault: Titles.DisplayTitleId == 0u); } @@ -1238,6 +1238,16 @@ public readonly record struct RuntimeCharacterTitleSnapshot( /// UpdateTitle (never re-add an optimistic write here — the CA /// campaign lesson). /// +/// +/// CT2 fix round (2026-08-24), F1: the NOTICE broadcast is unconditional — +/// retail's server-side SendNotice_AddCharacterTitle fires +/// regardless of prior membership — but the client-side table ADD is +/// DEDUPED: gmCharacterTitleUI::RecvNotice_AddCharacterTitle +/// @0x0049a990 walks mTitleList and returns without effect when +/// the id is already present, only inserting + adding the row on a miss. +/// models the client-side receive handler, +/// so fires only on a genuine new membership. +/// public sealed class RuntimeCharacterTitleState { private readonly object _gate = new(); @@ -1245,32 +1255,54 @@ public sealed class RuntimeCharacterTitleState private uint _displayTitleId; private long _revision; - /// Fires after a full 0x0029 CharacterTitle table replace. + /// + /// Fires after every 0x0029 CharacterTitle table replace, + /// unconditionally — matches retail's own + /// gmCharacterTitleUI::RecvNotice_UpdateCharacterTitleTable, + /// which always calls Refresh() regardless of whether the new + /// table differs from the old one. + /// public event Action? TableReplaced; /// - /// Fires once per 0x002B UpdateTitle arrival, UNCONDITIONALLY — - /// matches retail's own SendNotice_AddCharacterTitle, which - /// broadcasts regardless of whether the id was already in the earned - /// set. Carries the added title id. + /// Fires only when an 0x002B UpdateTitle arrival actually adds a + /// NEW id to the earned set — matches retail's client-side + /// gmCharacterTitleUI::RecvNotice_AddCharacterTitle @0x0049a990, + /// which dedupes against mTitleList before inserting (see the + /// class remarks: the F1 fix-round correction). Carries the added + /// title id. /// public event Action? TitleAdded; /// - /// Fires whenever the display title id changes — from either a fresh - /// 0x0029 table (a differing seed) or an 0x002B whose - /// setAsDisplay flag is set. Carries the NEW display title id. + /// Fires whenever the display title id actually changes — from either a + /// fresh 0x0029 table (a differing seed) or an 0x002B + /// whose setAsDisplay flag is set to an id that differs from the + /// current display title. Carries the NEW display title id. /// public event Action? DisplayTitleChanged; public uint DisplayTitleId => Volatile.Read(ref _displayTitleId); public long Revision => Interlocked.Read(ref _revision); + /// + /// WARNING: every read allocates a fresh array (ToArray() under + /// the gate). Fine for UI refresh call sites (CT3), but NEVER read this + /// per-frame — use or + /// for hot-path checks. + /// public IReadOnlyCollection EarnedTitleIds { get { lock (_gate) return _earnedTitleIds.ToArray(); } } + /// Non-allocating earned-title count; prefer this over + /// EarnedTitleIds.Count in hot paths (A3, CT2 fix round). + public int Count + { + get { lock (_gate) return _earnedTitleIds.Count; } + } + public bool HasEarnedTitle(uint titleId) { lock (_gate) return _earnedTitleIds.Contains(titleId); @@ -1290,20 +1322,32 @@ public sealed class RuntimeCharacterTitleState /// 0x0029 CharacterTitle — a WHOLESALE authoritative replace /// (retail's CharacterTitleTable::UnPack always rebuilds /// mTitleList from scratch; there is no incremental-merge path - /// on this opcode). + /// on this opcode). A4 (CT2 fix round): the whole mutation — set clear + /// + rebuild, display-id compare + write — happens under one + /// hold, with change flags computed inside the lock + /// and events raised only after it releases. A5: the revision counter + /// bumps only for an actual content/display change (a byte-identical + /// resend must not produce a revision edge); + /// itself still fires unconditionally, matching retail's own + /// unconditional Refresh() dispatch on this opcode. /// public void ReplaceTable(uint displayTitleId, IReadOnlyList titleIds) { ArgumentNullException.ThrowIfNull(titleIds); + bool setChanged; + bool displayChanged; lock (_gate) { + setChanged = !_earnedTitleIds.SetEquals(titleIds); _earnedTitleIds.Clear(); foreach (uint id in titleIds) _earnedTitleIds.Add(id); + displayChanged = _displayTitleId != displayTitleId; + if (displayChanged) + Volatile.Write(ref _displayTitleId, displayTitleId); } - bool displayChanged = DisplayTitleId != displayTitleId; - Volatile.Write(ref _displayTitleId, displayTitleId); - Interlocked.Increment(ref _revision); + if (setChanged || displayChanged) + Interlocked.Increment(ref _revision); TableReplaced?.Invoke(); if (displayChanged) DisplayTitleChanged?.Invoke(displayTitleId); @@ -1311,27 +1355,63 @@ public sealed class RuntimeCharacterTitleState /// /// 0x002B UpdateTitle — retail's - /// ClientUISystem::Handle_Social__AddOrSetCharacterTitle: ALWAYS - /// add, and additionally set-display only when - /// is true. + /// ClientUISystem::Handle_Social__AddOrSetCharacterTitle ALWAYS + /// broadcasts the add notice server-side, and additionally broadcasts + /// set-display only when is true. On + /// the CLIENT receive side this method models, F1 (CT2 fix round): the + /// add is deduped ( fires only when + /// HashSet<uint>.Add reports a genuine new membership, + /// matching gmCharacterTitleUI::RecvNotice_AddCharacterTitle + /// @0x0049a990's membership check before insert). A4: both halves + /// mutate under one hold with change flags computed + /// inside the lock; events raise after release. A5: the revision + /// counter bumps once per REAL change — zero times for an already- + /// earned id re-sent with pointing at + /// the already-current display id, up to twice when both halves change. /// public void ApplyUpdateTitle(uint titleId, bool setAsDisplay) { - lock (_gate) _earnedTitleIds.Add(titleId); - Interlocked.Increment(ref _revision); - TitleAdded?.Invoke(titleId); - if (setAsDisplay) + bool added; + bool displayChanged; + lock (_gate) { - Volatile.Write(ref _displayTitleId, titleId); - Interlocked.Increment(ref _revision); - DisplayTitleChanged?.Invoke(titleId); + added = _earnedTitleIds.Add(titleId); + displayChanged = setAsDisplay && _displayTitleId != titleId; + if (displayChanged) + Volatile.Write(ref _displayTitleId, titleId); } + if (added) + Interlocked.Increment(ref _revision); + if (displayChanged) + Interlocked.Increment(ref _revision); + if (added) + TitleAdded?.Invoke(titleId); + if (displayChanged) + DisplayTitleChanged?.Invoke(titleId); } + /// + /// A2 (CT2 fix round): publishes the clear like + /// LocalPlayerState.Clear() fires + /// unconditionally and fires when the + /// display id was non-zero before the clear, so a failed/retried reset + /// attempt can safely converge (process-lived views pull through this + /// object and use these events as their invalidation edge). The + /// revision counter itself stays unconditional, matching this class's + /// pre-existing reset contract. + /// public void ResetSession() { - lock (_gate) _earnedTitleIds.Clear(); - Volatile.Write(ref _displayTitleId, 0u); + uint previousDisplayTitleId; + lock (_gate) + { + previousDisplayTitleId = _displayTitleId; + _earnedTitleIds.Clear(); + Volatile.Write(ref _displayTitleId, 0u); + } Interlocked.Increment(ref _revision); + TableReplaced?.Invoke(); + if (previousDisplayTitleId != 0u) + DisplayTitleChanged?.Invoke(0u); } } diff --git a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs index 42e3b18e..e70ece3c 100644 --- a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs +++ b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs @@ -731,18 +731,22 @@ public sealed class DirectGameRuntimeCommandAdapter Validate(expectedGeneration, out WorldSession? session); if (gate != RuntimeCommandStatus.Accepted) return Result(gate); - if (titleId == 0u) - { - return EmitResult( - RuntimeCommandDomain.Character, - operation: 6, - RuntimeCommandStatus.Rejected); - } + // F3 (CT2 fix round, 2026-08-24): NO id-0 guard here — retail's own + // send path (Event_SetDisplayCharacterTitle @0x006a5720) packs + // whatever id it is handed, and ACE accepts id 0 + // (CharacterTitle.Invalid is a defined enum value). Retail's + // protection against sending an unearned/invalid id is the UI + // ghost-when-current gate (CT3's job), not a send-side rejection — + // a client-side guard here blocks a state the server honors. // CT2: NO optimistic local mutation — retail's own // Event_SetDisplayCharacterTitle send path touches no local state; // RuntimeCharacterState.Titles.DisplayTitleId updates only when the // server echoes UpdateTitle (0x002B) with setAsDisplay=true. session!.SendSetTitle(titleId); + // A6 (CT2 fix round): titleId rides the EmitResult primaryObjectId + // slot — same in-class precedent established for a typed + // domain-payload id, not always an object guid (see the S4 history + // comment on SaveOptions/EmitResult above). return EmitResult( RuntimeCommandDomain.Character, operation: 6, diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterTitleResolverLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterTitleResolverLiveDatTests.cs index bbf3ff9a..bf4a8876 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterTitleResolverLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterTitleResolverLiveDatTests.cs @@ -20,7 +20,8 @@ namespace AcDream.App.Tests.UI.Layout; public sealed class CharacterTitleResolverLiveDatTests { private static string DatDirectory => - Path.Combine( + System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine( System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile), "Documents", "Asheron's Call"); diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterTitleStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterTitleStateTests.cs index 0aa1f1a2..fed205fc 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterTitleStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCharacterTitleStateTests.cs @@ -119,19 +119,59 @@ public sealed class RuntimeCharacterTitleStateTests } [Fact] - public void ApplyUpdateTitle_AlreadyEarnedId_StillFiresTitleAddedUnconditionally() + public void ApplyUpdateTitle_AlreadyEarnedId_DoesNotFireTitleAddedOrBumpRevision() { - // Retail's own broadcast is unconditional — it does not check - // membership before firing the notice. + // F1 (CT2 fix round, 2026-08-24): the SERVER-side broadcast + // (SendNotice_AddCharacterTitle) is unconditional, but this method + // models the CLIENT-side receive handler + // (gmCharacterTitleUI::RecvNotice_AddCharacterTitle @0x0049a990), + // which walks mTitleList and returns without effect when the id is + // already present — only a genuine miss inserts + adds the row. var titles = new RuntimeCharacterTitleState(); titles.ApplyUpdateTitle(7u, setAsDisplay: false); + long revisionAfterFirstAdd = titles.Revision; var added = new List(); titles.TitleAdded += id => added.Add(id); titles.ApplyUpdateTitle(7u, setAsDisplay: false); - Assert.Equal([7u], added); + Assert.Empty(added); Assert.Single(titles.EarnedTitleIds); + Assert.Equal(revisionAfterFirstAdd, titles.Revision); + } + + [Fact] + public void ApplyUpdateTitle_SetAsDisplayOnAlreadyCurrentId_DoesNotFireDisplayTitleChangedOrBumpRevision() + { + // A5 (CT2 fix round): a re-notice for the id that is ALREADY the + // display title is a no-op wire message — it must not produce a + // revision edge or a spurious DisplayTitleChanged. + var titles = new RuntimeCharacterTitleState(); + titles.ApplyUpdateTitle(13u, setAsDisplay: true); + long revisionAfterFirst = titles.Revision; + var displayChanged = new List(); + titles.DisplayTitleChanged += id => displayChanged.Add(id); + var added = new List(); + titles.TitleAdded += id => added.Add(id); + + titles.ApplyUpdateTitle(13u, setAsDisplay: true); + + Assert.Empty(displayChanged); + Assert.Empty(added); + Assert.Equal(revisionAfterFirst, titles.Revision); + } + + [Fact] + public void ApplyUpdateTitle_AddsNewIdAndSetsDisplay_BumpsRevisionTwice() + { + // A5: a single 0x002B that both adds a NEW id and changes the + // display title bumps the revision once per real half-change. + var titles = new RuntimeCharacterTitleState(); + long before = titles.Revision; + + titles.ApplyUpdateTitle(13u, setAsDisplay: true); + + Assert.Equal(before + 2, titles.Revision); } [Fact] @@ -147,6 +187,25 @@ public sealed class RuntimeCharacterTitleStateTests Assert.Equal(99u, titles.DisplayTitleId); } + [Fact] + public void ReplaceTable_IdenticalResend_DoesNotBumpRevision_ButStillFiresTableReplaced() + { + // A5: a byte-identical 0x0029 resend (same table, same display id) + // is a no-op wire message for the revision counter. TableReplaced + // itself still fires unconditionally — retail's own + // RecvNotice_UpdateCharacterTitleTable always Refresh()es. + var titles = new RuntimeCharacterTitleState(); + titles.ReplaceTable(13u, [1u, 5u, 13u]); + long revisionAfterFirst = titles.Revision; + int tableReplacedCount = 0; + titles.TableReplaced += () => tableReplacedCount++; + + titles.ReplaceTable(13u, [1u, 5u, 13u]); + + Assert.Equal(revisionAfterFirst, titles.Revision); + Assert.Equal(1, tableReplacedCount); + } + [Fact] public void ResetSession_ClearsEarnedIdsAndDisplayTitle() { @@ -170,6 +229,57 @@ public sealed class RuntimeCharacterTitleStateTests Assert.True(titles.Revision > before); } + [Fact] + public void ResetSession_NonEmptyState_FiresTableReplacedAndDisplayTitleChanged() + { + // A2 (CT2 fix round): matches the LocalPlayerState.Clear() precedent + // — publish every category even when Clear is repeated, so a failed + // reset attempt can safely converge on retry. + var titles = new RuntimeCharacterTitleState(); + titles.ReplaceTable(13u, [1u, 5u, 13u]); + int tableReplacedCount = 0; + titles.TableReplaced += () => tableReplacedCount++; + var displayChanged = new List(); + titles.DisplayTitleChanged += id => displayChanged.Add(id); + + titles.ResetSession(); + + Assert.Equal(1, tableReplacedCount); + Assert.Equal([0u], displayChanged); + } + + [Fact] + public void ResetSession_RepeatedReset_StillFiresTableReplacedButNotDisplayTitleChanged() + { + // A2: TableReplaced publishes unconditionally on every reset; a + // SECOND reset (display id already 0) must not re-fire + // DisplayTitleChanged — there is no real transition to report. + var titles = new RuntimeCharacterTitleState(); + titles.ResetSession(); + int tableReplacedCount = 0; + titles.TableReplaced += () => tableReplacedCount++; + var displayChanged = new List(); + titles.DisplayTitleChanged += id => displayChanged.Add(id); + + titles.ResetSession(); + + Assert.Equal(1, tableReplacedCount); + Assert.Empty(displayChanged); + } + + [Fact] + public void Count_ReflectsEarnedTitleIdsWithoutAllocatingTheArray() + { + // A3 (CT2 fix round): non-allocating count accessor for hot paths + // like RuntimeCharacterState.CaptureOwnership. + var titles = new RuntimeCharacterTitleState(); + Assert.Equal(0, titles.Count); + + titles.ReplaceTable(13u, [1u, 5u, 13u]); + + Assert.Equal(3, titles.Count); + } + [Fact] public void Snapshot_ReflectsDisplayTitleAndCount() { diff --git a/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs b/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs index e61390e0..18a3d679 100644 --- a/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs +++ b/tests/AcDream.Runtime.Tests/Session/DirectGameRuntimeCommandAdapterTests.cs @@ -376,8 +376,14 @@ public sealed class DirectGameRuntimeCommandAdapterTests } [Fact] - public void SetTitle_ZeroTitleId_RejectsWithoutSendingAnything() + public void SetTitle_ZeroTitleId_StillSendsTheWireAction() { + // F3 (CT2 fix round, 2026-08-24): retail's own send path + // (Event_SetDisplayCharacterTitle @0x006a5720) packs whatever id it + // is handed, with no id-0 rejection — and ACE accepts id 0 + // (CharacterTitle.Invalid is a defined enum value). A client-side + // id-0 guard here would block a state the server honors; retail's + // real protection is the UI ghost-when-current gate (CT3's job). (GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) = CreateStartedHarness(); var gameActions = new List(); @@ -387,8 +393,13 @@ public sealed class DirectGameRuntimeCommandAdapterTests runtime.Generation, titleId: 0u); - Assert.Equal(RuntimeCommandStatus.Rejected, result.Status); - Assert.Empty(gameActions); + Assert.True(result.Accepted); + Assert.Single(gameActions); + byte[] sent = gameActions[0]; + Assert.Equal( + SocialActions.TitleSetOpcode, + BinaryPrimitives.ReadUInt32LittleEndian(sent.AsSpan(8))); + Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(sent.AsSpan(12))); runtime.Dispose(); } From b5d36f5211df437c7a032b4f2ff88379f001d782 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 22:28:01 +0200 Subject: [PATCH 41/89] docs(CT): CT2 review-closed (fix round 544f8cb2) Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-24-character-panel-parity-campaign.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index 22e7524b..47f89c23 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -146,7 +146,7 @@ rows), the value-column right margin, header element fonts/colors min/max constraints. Output: research doc + InstalledDat pins (the tooltip/scrollbar-pin pattern). No production changes. -**CT2 — Runtime title ownership + wire. LANDED 2026-08-24.** Parsed +**CT2 — Runtime title ownership + wire. REVIEW-CLOSED 2026-08-24: landed `bcfddc97`, Opus review (0 blockers, 4 should-fix), fix round `544f8cb2`.** Parsed `0x0029 CharacterTitle` (retail's `CharacterTitleTable::UnPack @0x005c6e90` — the leading ACE `1u`/retail-Pack-constant field is discarded, matching retail's own read) and `0x002B UpdateTitle` From 03e073b748d018f598009a03fd8ce11e09795c0b Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 22:59:40 +0200 Subject: [PATCH 42/89] =?UTF-8?q?feat(ui):=20Campaign=20CT=20slice=20CT3?= =?UTF-8?q?=20=E2=80=94=20Titles=20page=20live=20via=20standard=20GUI=20cl?= =?UTF-8?q?asses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Titles tab (AP-109's known-inert gap) now switches to a real page and CharacterTitlesController binds it entirely through UiTemplateListBox/ UiScrollbar/UiButton — zero bespoke widgets, matching every other social/options row-list page in this codebase. Retail anchors: gmCharacterTitleUI::PostInit @0x0049A610; AddTitleToList @0x0049A840 + FindSortedInsertPosition @0x0049A760 (rows sorted by resolved display text — this port rebuilds the full sorted set on every change rather than a positional splice, since UiTemplateListBox has no insert-at-index primitive and no other consumer needs one either); InfoRegion::SetState(selected?6:1) (row Highlight/DirectState swap, the same mechanism CT1's SEALED VERDICT confirmed for the stat rows); UpdateButtons @0x0049A500 CORRECTED direction (Ghosted unless a row is selected whose id differs from the current display title — no selection IS the Ghosted case); Refresh @0x0049abc0 (display-title text, including the hardcoded "Unknown" fallback, refreshed on both TableReplaced and DisplayTitleChanged per CT2's review anchor 1); Event_SetDisplayCharacterTitle @0x006a5720 (wire-only TitleSet 0x002C send, no local mutation). CharacterStatController.Bind now three-way switches Attributes/Skills/ Titles — Titles is a genuinely separate, non-duplicated page container (CT1 ground truth §3), unlike Attributes/Skills which share one mounted page and only rebind content. The two page captions (0x1000052E/0x10000531) are left untouched: LayoutImporter.BuildText already resolves every element's authored StringInfo caption at import time, so no controller-side string lookup was added. New IGameRuntimeCommands.SetTitle seam on DeferredGameRuntimeStateCommands (InteractionUiRuntimeSources.cs) mirrors the existing Advance() shape. CharacterRuntimeBindings gains Titles/TitleResolver/SendSetTitle; CharacterTitleResolver (CT2) is constructed once at composition time and its .Resolve method group is passed to the controller as a delegate (not the concrete DAT-backed type) so the controller stays hermetically testable without a live IDatReaderWriter. Tests (tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs): binding-seam coverage against the REAL committed character_2100002E.json fixture (verified this session to already carry the Titles page subtree, including the ListBox's own authored TemplateList=[(0x2100005E, 0x10000536)] entry — RowTemplateResolver_ReceivesTheFixturesOwnAuthoredTemplateIds proves the controller reads that authored pair, not a hardcoded one); a hand-authored ElementInfo standing in only for the row template itself (a separate LayoutDesc with no committed fixture yet — CT1 was a live-DAT probe only); sorted-row order, Unknown fallback, row selection/highlight, the ghost truth table (no selection / selected==display / selected!= display), click-sends-exactly-one-SetTitle-and-mutates-nothing, click-while-ghosted-sends-nothing, TableReplaced rebuild (including selection survival when the id is still earned), TitleAdded single-row growth, DisplayTitleChanged text+ghost refresh, and Dispose unsubscription. CharacterStatControllerTests updated for the Titles tab no longer being ClickThrough, plus a new tab-switch visibility test. Register: amends AP-109 (docs/architecture/retail-divergence-register.md) to record the Titles-page half as LIVE; the header identity block and luminance fields remain open for CT4. Suites: full solution 15,405 tests / 0 skips (App 6,130) green. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 2 +- .../InteractionRetainedUiComposition.cs | 11 +- .../InteractionUiRuntimeSources.cs | 9 + .../UI/Layout/CharacterStatController.cs | 56 ++- .../UI/Layout/CharacterTitlesController.cs | 341 +++++++++++++++ src/AcDream.App/UI/RetailUiRuntime.cs | 50 ++- .../UI/Layout/CharacterStatControllerTests.cs | 44 +- .../Layout/CharacterTitlesControllerTests.cs | 414 ++++++++++++++++++ 8 files changed, 913 insertions(+), 14 deletions(-) create mode 100644 src/AcDream.App/UI/Layout/CharacterTitlesController.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 8f828cb9..56cb2c22 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -356,7 +356,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-105 | **PARTIAL 2026-07-13** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`), and both chat backends share typed client-command routing plus one retained `ChatVM` for reply state. Retained chat still lacks complete tab/filter/unread, social availability, incoming squelch enforcement, and focus-opacity behavior. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, command ownership, and outer maximize geometry work; later chat work consolidates the remaining presentation/filter state | Tabs are no-ops, squelched lines can still render, contextual social actions are absent, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods | | ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | | AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` | -| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` | +| AP-109 | **NARROWED 2026-08-24 at Campaign CT slice CT3 — the Titles-page half is now LIVE.** `CharacterTitlesController` binds the Titles page (`gmCharacterTitleUI`, LayoutDesc `0x2100002E` element `0x10000539`) through the standard `UiTemplateListBox`/`UiScrollbar`/`UiButton` classes — no bespoke widgets: the earned-titles list sorted by resolved display string (`FindSortedInsertPosition @0x0049A760`), the current display-title text (`Refresh @0x0049abc0`, including its hardcoded `"Unknown"` fallback, refreshed on both the table-replace and display-change notices), row selection using the row template's own authored Highlight state (the same `InfoRegion::SetState(6)` mechanism CT1 confirmed for the stat rows), the "Set as Display Title" button's Ghosted-unless-a-differing-selection gate (`UpdateButtons @0x0049A500`, CORRECTED direction per the CT campaign plan's CT1 fix round — no selection is the Ghosted case), and the `TitleSet (0x002C)` wire send through CT2's `RuntimeCharacterTitleState`/`IRuntimeCharacterCommands.SetTitle` (no local mutation). What remains open: the header identity block's live displayed-title composition and the level-200 luminance fields (both CT4's job). | `src/AcDream.App/UI/Layout/CharacterTitlesController.cs`; `src/AcDream.App/UI/Layout/CharacterStatController.cs` (three-way Titles tab switch); `src/AcDream.App/UI/RetailUiRuntime.cs` (`MountCharacter`'s title row-template resolver + `CharacterRuntimeBindings`); `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted; the Titles page's binding SEAM (not just its layout/logic) is asserted by CT3's fixture tests | The header identity block (name / heritage+display-title / PK line) and the luminance fields remain unported until CT4 lands | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` | | AP-110 | **NARROWED 2026-08-09 (Slice 5.4, vendor browse panel) — "vendor" retired from the absent-panels list; see AP-161 for the precise successor (Buy/Sell transaction UI, Slice 6).** Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, inscription transaction, retail creature stat/rating/animated-preview presentation, default selection-follow, authored local spell subview with appropriate-formula component state, and the full EoR item-report dispatch: appraisal-only unknowns; exact equipment-set/rating/tinkering/weapon/armor/caster/requirement/XP/healer/rare prose and intentional blank section rows; ordinary/enchantment DAT spell descriptions; live material-decorated appropriate titles plus DAT material and creature names; expiry, decorated material/gem descriptions; and portal/PK restrictions with authored item colors. It still lacks item-object preview, player-dependent effective shield projection, live cooldown-remaining projection, localized augmentation-cost `StringInfo`, exhaustive character detail regions, and exact creature appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, the Slice 5.4 vendor "Items" browse panel (category-filtered stock list, retail's quantity-correct pricing — `ItemHolder::GetObjectSplitSize`'s split-exempt mask, not a flat per-unit price), and the core examination request/presentation/inscription/creature-preview/item-report loop cover the active loops; the residual examination mechanisms require live player/enchantment/localization state or object-preview ownership rather than fabricated content | Item assessments omit only the listed live/localized/preview projections; enchanted/incomplete creature appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `gmExaminationUI::ExamineSpell @ 0x004B6900`; `SpellExamineUI::ExamineSpell @ 0x004B6210`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ACCWeenieObject::GetObjectName @ 0x0058E6E0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::AddItemInfo @ 0x004AC050`; `ItemExamineUI::Appraisal_ShowCapacity @ 0x004B2680`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::Appraisal_ShowDescription @ 0x004B6990`; `MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog | | AP-161 | **REVIEW CORRECTIONS 2026-08-09 (Opus review of `92ea3977`, findings F1-F13):** thirteen further fixes, mostly bug-fixes-to-already-claimed-behavior rather than new divergences, so no new AP row is filed for most of them; the exceptions are called out below. F1 ports Buy All's four retail pre-send guards (pyreal/alt-currency affordability, container-slot/item-slot capacity) — see AP-162's narrowing. F2 corrects `AddToBuyList` from upsert to retail's actual ACCUMULATE-with-5000-cap semantics and ports `RemoveFromShop`'s shop-row hide/restore as staging consumes limited vendor supply. F3 corrects `VendorSellAcceptability`'s too-valuable branch to the byte-verified bitwise-complement form (`(~(itemTypeMask >> 16)) & 4`), exempting `PromissoryNote` items. F4 wires `BF_RETAINED` end to end, RETIRING AP-164 below. F5 ports `UpdateDragOver`'s auto-switch-to-Selling-on-hover. F6 corrects sell staging to always record the FULL stack (never the live split slider) and ports `SellSingleItem`'s partial-stack refusal plus its literal amount-1 send. F7 corrects the X-close confirmation string's missing trailing question mark. F8 disposes a live confirmation dialog on session Close/Reset. F9 repaints the Buying/Selling strips' own selection highlight on every selection change, not just a staging change. F10 unstages a sell entry that leaves `ClientObjectTable` and a buy entry whose shop row is retired, the latter with retail's exact notice. F11 reorders `RequestUse`'s eligibility check ahead of `BeginApproach` so an ineligible far target no longer speculatively approaches. F13 makes Sell Item act on the global selection unconditionally, matching retail — a prior version of this port required a staged entry first. New approximations this pass introduced are filed as AP-167 (`SellSingleItem`'s non-empty-container refusal branch not ported) and AP-168 (Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container`). **NARROWED 2026-08-09 (Slice 6b/6c, staging+sell arc) — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES.** `VendorUiController` now fully wires both tabs: Buying (`Add to List`/`Buy Item`/`Buy All`/`Clear Item`/`Clear List`, backed by `VendorStagingList`) and Selling (drag-to-sell via `IItemListDragHandler`, `VendorSellAcceptability`'s port of `VendorProfile::InqAcceptability`, `Sell Item`/`Sell All`/`Clear Item`/`Clear List`), plus the X-close staging confirmation dialog (`RetailDialogFactory`, the exact retail string recovered from the decompiled binary's data segment at `0x007b5bd8`). Sell (`0x0060`) is wired end to end (`VendorRequests.BuildSell`/`WorldSession.SendSell`/`ItemInteractionController.TrySell`). Three narrow residuals from this pass are filed separately rather than folded in here: `InqAcceptability`'s non-sellable bitfield check is unmodeled (AP-164), the Buying tab's stackable-removal-amount test substitutes `VendorShopItem.DescStackSize` for retail's `_maxStackSize` (AP-165), and the Buying/Selling tabs' own per-row/purse count text plus the cross-panel "pending sell" inventory highlight are not wired (AP-166). The two PRE-EXISTING residuals below (dropdown arrow-cap glyph, alt-currency `m_last_sale` simplification) are UNCHANGED by this pass — see the ORIGINAL text below for their citations. **REVIEW CORRECTIONS 2026-08-09 (Opus review of `97cf8738`, findings F1-F9):** none of these are NEW divergences from retail — they are bug fixes that make this row's own claims actually true, so no new AP row is filed for them. F2 fixed the priced/named quantity freezing at a selection-time seed while the Buy button separately read the LIVE slider — both now share one `ResolveBuyQuantity` computation, so the displayed price always equals what a purchase actually charges (retail: `gmVendorUI::RecvNotice_StackSliderChanged` re-runs the SAME display update on every slider change, `pc:203262-203278`). F6 corrected an unauthored "preserve the prior selection if it survives the filter" rule to retail's actual UNCONDITIONAL reselect-to-first-item on every rebuild this controller reaches (`VendorItemsUI::UpdateItemsList`'s notify=1 path, `pc:201180-201184`, confirmed reached by a fresh open AND a same-vendor refresh via `VendorItemsUI::OpenVendor`'s unconditional `SetSelectedItem(...,1)`, `pc:201022`). F7 ported `BuySingleItem`'s stack-size-1 quantity clamp (`pc:201674-201681`) so a stale slider value left over from a previously-selected, DIFFERENT stackable item cannot leak into a non-stack purchase. F8 is recorded inline below, where it corrects this row's own stale claim about the Add-to-List button. **NARROWED 2026-08-09 (Slice 6.1-6.3, buy arc) — TWO of the four consciously-deferred residuals below CLOSE.** Private per-panel selection is GONE: `SelectionState` gains a `Vendor` change source (`SelectionChangeSource.Vendor`) and is now the AUTHORITY — row clicks, the F4 auto-select-first-item fallback, and right-click examine all call `SelectionState.Select`/`Clear`; `VendorUiController` is a CONSUMER (`OnSelectionTransition`) exactly like every sibling panel, matching retail's global `ACCWeenieObject::selectedID`. The examine gap (F7c) is GONE too: `VendorShopItemMaterializer` (`src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs`, Slice 6.1) registers every `ApproachVendor` shop item into `ClientObjectTable` (guid, `ContainerId = vendorGuid`, merge-upserted via the ordinary `Ingest` path, retired on session Close/Reset/vendor-replace via the SAME `VendorState.Changed` subscription) so `AppraisalUiController.Apply`'s lookup now succeeds; `VendorUiController.ExamineItem` wires `UiItemList.ExamineItemRequested` to `ItemInteractionController.ExamineSelectedOrEnterMode`, mirroring `ExternalContainerController`. **Double-click-to-buy was investigated (research doc `docs/research/2026-08-08-slice6-vendor-transactions-research.md` §B.2) and confirmed ABSENT from retail** — no `gmVendorUI::CheckForDoubleClick`/`VendorItemsUI::CheckForDoubleClick` symbol exists anywhere in the 18,366-function named table, unlike sibling panels (`gmContractsUI::CheckForDoubleClick`, `gmPageListUI::CheckForDoubleClick`) that DO have one; acdream intentionally does NOT add a double-click shortcut — a user request for it as a deliberate acdream-only UX addition would need its own AP row, per CLAUDE.md's no-invented-mechanisms discipline. The remaining two residuals (dropdown arrow-cap glyph, alt-currency `m_last_sale` simplification) are UNCHANGED — see below. New approximations this pass introduced are filed separately as AP-162 (no client-side Buy pre-check) and AP-163 (shop-item guid-collision policy). **Original REWRITTEN text follows, retained for the two still-open residuals:** `VendorUiController` mounts LayoutDesc `0x21000012`/root `0x100000B7` and fully wires only the authored "Items" tab (`0x100000B9` — `VendorItemsUI`: category-filtered browse list with retail's quantity-correct pricing, `ItemHolder::GetObjectSplitSize`'s `0xDC41CB0` split-exempt mask ported locally rather than a flat per-unit price). The other two authored tabs render and switch pages (so the layout looks complete) but are otherwise INERT: "Buying" (`0x100000BA`, `VendorBuyUI` — staged-purchase review/confirm, buttons `0x100000C9`/`CA`/`CB`/`CC`) and "Selling" (`0x100000BB`, `VendorSellUI` — staged-sale review/confirm, buttons `0x100000D2`/`D3`/`D4`/`D5`) have no data binding at all. The "Items" page's own `Buy` button (`0x100000C2`) correctly enables/disables with selection (`UiButton.Enabled`, retail `SetState(1)`/`SetState(0xd)`) and Slice 6.3 wires it to a real immediate single-item purchase (`gmVendorUI::BuySingleItem`, `pc:201661` — `VendorRequests.BuildBuy`/`WorldSession.SendBuy`, opcode `0x005F`). **Review correction 2026-08-09 (F8):** `Add to List` (`0x100000C3`, staging) does NOT enable/disable with selection — it is PERMANENTLY disabled (`VendorUiController.SetActionButtonsEnabled`), because it has no wired `OnClick` at all; an enabled-but-dead button is a worse affordance than a disabled one, so it stays disabled until the "Buying" tab's staging list is actually implemented. The Buy opcode exists on the wire now; Sell (`0x0060`) does not. `VendorProfile::InqAcceptability` (sell-eligibility filtering) is unread — moot without a sell UI. Two divergences remain of the four the F1-F8 fix pass originally recorded — the other two (private per-panel selection, unwired shop-item examine) CLOSED at Slice 6.1/6.2, see the NARROWED note above: (1) the closed-dropdown button face reuses the row template's own two sprites (`0x060012B3` normal/`0x060012B4` open) through `UiMenu`'s existing single-texture 3-slice `DrawButtonFace` instead of retail's authored two-piece label+arrow-cap assembly (label `0x1000034D` + a separate 17x19 arrow cap `0x1000034E` with its own `0x060012B1`/`0x060012B2` states) — a cosmetic gap only; the popup panel and its rows render with the exact authored geometry and sprites; (2) the alt-currency "you have" holding reads `VendorShopProfile.AlternateCurrencyAmount` directly instead of tracking retail's `gmVendorUI.m_last_sale` purchase debit — moot until a sell path exists to actually debit it, since `m_last_sale` only changes on a completed SALE (retail's own `m_last_sale == 0` case, `pc:204091`/`OpenVendor`'s `this->m_last_sale = 0` reset at `pc:203790`/`203800`); Slice 6.3's buy path does not touch `m_last_sale` either (retail's own buy flow never writes it), so this residual is unaffected by the buy arc landing. The "Buying"/"Selling" staging tabs (`VendorBuyUI`/`VendorSellUI`) and the full Sell wire remain unwired — unchanged Slice 6b/6c territory per contract decision 6, not a residual of THIS row. | `src/AcDream.App/UI/Layout/VendorUiController.cs`; `src/AcDream.Core/Items/VendorState.cs`; `src/AcDream.Core.Net/GameEventWiring.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs` | Slice 6 (`docs/plans/2026-07-23-world-interaction-completion.md`) owns the authoritative buy/sell transaction command, quantity/stack-split selection, drag-to-sell consumption, and `InqAcceptability`-gated sell UI — Slice 5.4's charter was browse only. Buy (6.3), the global `ACCWeenieObject::selectedID` coupling (6.2), and shop-item `ClientObjectTable` registration (6.1) are now DONE, landing exactly the seam this row's original filing fenced off; drag-to-sell consumption and `InqAcceptability`-gated sell UI remain Slice 6b/6c territory. | A player can browse, select, examine, and BUY (Slice 6.3) — the only remaining unbuilt transaction is Sell. Clicking "Buying"/"Selling" still shows an empty page with no error or explanation, matching "present but does nothing" rather than a disabled/hidden affordance. The dropdown's closed-state button face is missing its separate arrow-cap glyph — a minor visual gap, not a functional one; the open popup itself is pixel-faithful to the authored template. | `gmVendorUI::OpenVendor` pc:203650 (`m_itemsUI`/`m_buyUI`/`m_sellUI` construction, `PostInit` pc:199906, `m_last_sale` reset pc:203790/203800); `VendorBuyUI::VendorBuyUI` pc:199717; `VendorSellUI::VendorSellUI` pc:199753; `VendorProfile::InqAcceptability` pc:484768-484797; `UIElement_Menu::MakePopup` pc:120705-120764, `::Initialize` pc:120789-120828; `VendorItemsUI::UpdateItemsUI` pc:202539-202820; `VendorItemsUI::UpdateItemsList` pc:201029-201190; `ItemHolder::GetObjectSplitSize` pc:401465-401477; `gmToolbarUI::HandleSelectionChanged` pc:198740-198790 (mask `0xDC41CB0` at pc:198784); `ACCWeenieObject::GetObjectName` pc:409056-409132; `docs/research/2026-08-08-slice5-vendor-browse-research.md` §B.4, §D | | AP-162 | **NARROWED 2026-08-09 (Opus review of `92ea3977`, finding F1) — the "Buy All" half of this row CLOSES.** `VendorUiController.BuyAllButtonPressed` now ports all four of retail's client-side pre-send guards (pyreal affordability `pc:204017`, alt-currency affordability `pc:204032`, container-slot capacity `pc:204053`, item-slot capacity `pc:204067`) — see `ComputeBuyTransactionValue`/`ComputeBuySlotsNeeded`/`CountPlayerContents`, each guard returning with staging fully intact and retail's own exact notice string (`"You don't have enough money"` at `0x007b57b4`, `"You must empty some slots in your backpack first"` at `0x007b5750`, both byte-recovered). The container-vs-item slot CLASSIFICATION this port uses (`ItemType.Container` instead of retail's bitfield/capacity test) is its own new, narrower approximation — filed separately as AP-168 rather than folded in here. Only `TryBuy`'s single-item Buy path (Items tab's own Buy button, and the Buying tab's "Buy Item") remains WITHOUT a client-side pre-check — the risk/oracle columns below now describe that one remaining case, not both. **EXTENDED 2026-08-09 (Slice 6b) — the same omission now also covers "Buy All".** `ItemInteractionController.TryBuyAll` (the batched-send path `VendorUiController.BuyAllButtonPressed` calls) sends unconditionally too, without porting retail's `pc:204017/204032/204053/204067` affordability/pack-capacity pre-checks for the MULTI-item case either — the same latency-not-correctness tradeoff this row already documents for the single-item path, extended rather than duplicated into a second row; retiring this row should port both the single- and batched-send pre-checks together. **Filed 2026-08-09, Slice 6.3 (buy wire + button).** Retail's `BuySingleItem` (`pc:201661`) performs TWO client-side pre-checks before ever sending `CM_Vendor::Event_Buy`: (a) an affordability check against `this->m_totalValue` (pyreal) or `shopVendorProfile->trade_num - m_last_sale` (alt-currency), showing a LOCAL string via `ECM_UI::SendNotice_DisplayStringInfo` and returning without sending anything on failure (`pc:201686-201717`); (b) a pack/container-capacity pre-check (`pc:201730-201746`) mirroring the server's own check. acdream's `ItemInteractionController.TryBuy` sends unconditionally once the shared use/inventory gate is free — no client-side affordability or capacity check runs before dispatch. Every refused purchase pays a full round-trip (send → server rejects → `UseDone`/`GameEventInventoryServerSaveFailed`) instead of failing instantly and silently client-side. **Swept 2026-08-09 (F4 review fix):** `TryBuy` now also checks whether `sendBuy` actually reached a live, in-world session before marking the reservation dispatched — an orthogonal reservation-leak bug fix (no session ever produced a stray permanent busy-lock), not an affordability/capacity check; this row's scope and residual are unchanged. | `src/AcDream.App/UI/ItemInteractionController.cs` (`TryBuy`) | The research doc's own open question 1 (`docs/research/2026-08-08-slice6-vendor-transactions-research.md`) recommends deferring this: the server is authoritative either way (ACE re-validates both affordability and capacity server-side — `Vendor.BuyItems_ValidateTransaction`, `Vendor.cs:431-571`), so omitting the client pre-check is a LATENCY/UX gap, not a correctness one — a refused purchase still fails cleanly, just one round-trip later than retail. | A player attempting to buy something they cannot afford or have no room for sees the failure arrive after a network round-trip instead of instantly; against a well-behaved ACE server no purchase can succeed that retail's pre-check would have blocked, so no transaction outcome differs — only its latency. Retiring this row means porting `BuySingleItem`'s two pre-check branches (`pc:201686-201746`) into `TryBuy` before dispatch. | `gmVendorUI::BuySingleItem` pc:201661/0x004C2820 (affordability pc:201686-201717, capacity pc:201730-201746); `Vendor.BuyItems_ValidateTransaction` (`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:431-571`); `docs/research/2026-08-08-slice6-vendor-transactions-research.md` §D point 4, Open question 1 | diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index d74658b3..6bd8a257 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -554,6 +554,11 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory RuntimeAdvancementKind.TrainSkill, statId, credits)); + // Campaign CT slice CT3 (2026-08-24): the Titles page's DAT + // id -> display-string chain (CT2). Constructed once — its own + // constructor does no DAT I/O (only .Resolve reads touch the + // dats), matching the characterCreationStrings precedent below. + var characterTitleResolver = new CharacterTitleResolver(d.Dats); checkpoint(InteractionRetainedUiCompositionPoint.CharacterSheetCreated); uint MagicSkillLevel(MagicSchool school) @@ -822,7 +827,11 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory && d.Inventory.Objects.Get(guid) is { } vendorCandidate && vendorCandidate.ContainerId == d.Inventory.Vendor.VendorId && VendorSplitPolicy.IsSplitExempt(vendorCandidate.Type)), - Character: new CharacterRuntimeBindings(characterSheet), + Character: new CharacterRuntimeBindings( + characterSheet, + d.Character.Titles, + characterTitleResolver, + SendSetTitle: titleId => late.GameRuntime.SetTitle(titleId)), Inventory: new InventoryRuntimeBindings( d.Inventory.Objects, () => d.PlayerIdentity.ServerGuid, diff --git a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs index 87f8b1cd..afaaf7ae 100644 --- a/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs +++ b/src/AcDream.App/Composition/InteractionUiRuntimeSources.cs @@ -150,6 +150,15 @@ internal sealed class DeferredGameRuntimeStateCommands generation, new RuntimeAdvancementCommand(kind, statId, cost))); + /// Campaign CT slice CT3 (2026-08-24): retail TitleSet + /// (0x002C) — same generation-capturing late seam as every other + /// method here. + /// is the only caller. + public RuntimeCommandResult SetTitle(uint titleId) => + Invoke((commands, generation) => commands.Character.SetTitle( + generation, + titleId)); + // Campaign LA slice LA8: the retained character-management screen uses // the same generation-capturing late seam as every gameplay panel. The // screen never receives GameRuntime or WorldSession and cannot retain a diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index 0cdfad33..5ad9865b 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -35,10 +35,15 @@ namespace AcDream.App.UI.Layout; /// line-1 label = "Experience To Raise:", line-1 value = raise cost, /// line-2 label = "Unassigned Experience:", line-2 value = UnassignedXp. /// -/// Tab states: Attributes = "Open", Skills = "Closed", Titles = "Closed". -/// Source: UIElement::SetState @ 0x00464E70 and UIStateId.Open (0x0C) / -/// UIStateId.Closed (0x0B). The imported Type-12 tab owns its authored font color and -/// propagates the state to its three chrome children through PassToChildren. +/// Tab states: whichever of Attributes/Skills/Titles is active shows "Open" +/// (0x0C); the other two show "Closed" (0x0B) — Attributes is the retail-authored +/// default. Source: UIElement::SetState @ 0x00464E70. The imported Type-12 +/// tab owns its authored font color and propagates the state to its three chrome +/// children through PassToChildren. Campaign CT slice CT3 (2026-08-24) wires the +/// Titles tab click to a real page switch (previously a known-inert AP-109 gap); +/// owns the Titles page's OWN content +/// (row list, display-title text, Set-as-Display button) once its page container +/// is shown. /// /// Raise buttons: 0x10000246 (×1) + 0x100005EB (×10). State "Normal" = affordable /// (UIStateId.Normal, 0x01), state "Ghosted" = unaffordable or no selection @@ -146,6 +151,7 @@ public static class CharacterStatController { Attributes, Skills, + Titles, } public enum RaiseTargetKind @@ -278,6 +284,11 @@ public static class CharacterStatController UiElement? skillsTab = layout.FindElement(TabSkillsId); UiElement? titlesTab = layout.FindElement(TabTitlesId); UiElement? contentPage = FindDirectChildById(layout.Root, AttributesPageId); + // CT3: unlike Attributes/Skills (which share ONE mounted page and just + // rebind its content — see the Attributes/Skills tab-switch note above), + // the Titles page (0x10000539) is its OWN separate, non-duplicated subtree + // (CT1 ground truth §3) that must be actually shown/hidden. + UiElement? titlesPage = FindDirectChildById(layout.Root, TitlesPageId); // Name (18px from dat FontDid), Heritage (14px), PkStatus (14px): // Fix C: pass null → Label's null-guard keeps the build-time dat font. @@ -472,9 +483,12 @@ public static class CharacterStatController RetailTabBinding.SetClick(attributesTab, () => SwitchTab(CharacterStatTab.Attributes)); RetailTabBinding.SetClick(skillsTab, () => SwitchTab(CharacterStatTab.Skills)); - // Titles remain the known AP-109 gap. Keep its retail-authored closed visual, - // but do not make an inert page look interactive until that controller lands. - RetailTabBinding.SetClick(titlesTab, null); + // CT3 (2026-08-24): the Titles tab now switches to its own real page + // (previously the known AP-109 gap — retail-authored closed visual, but + // no click routing at all). Content population is + // CharacterTitlesController's job, bound separately by the caller + // against the SAME imported layout root. + RetailTabBinding.SetClick(titlesTab, () => SwitchTab(CharacterStatTab.Titles)); UpdateTabStates(); // ── Active-page selection (fixes the dark-overlay) ───────────────────── @@ -501,8 +515,30 @@ public static class CharacterStatController attrSel[0] = -1; skillSel[0] = -1; SetFooterSelected(false); - RebuildActiveList(); - RefreshActiveRaiseButtons(); + + // CT3: Titles is a genuinely separate page container (unlike + // Attributes/Skills, which share one mounted page and only rebind + // its content — see RebuildActiveList) — actually flip visibility + // between it and the shared Attributes/Skills content page. + bool showTitles = tab == CharacterStatTab.Titles; + if (titlesPage is not null) titlesPage.Visible = showTitles; + if (contentPage is not null) contentPage.Visible = !showTitles; + + if (showTitles) + { + // Titles authors its own (unused) copies of the raise buttons + // (CT1 ground truth); nothing on this page ever selects a stat + // row, so keep them hidden rather than rebuilding a list this + // tab does not show. + foreach (var b in allRaise1) b.Visible = false; + foreach (var b in allRaise10) b.Visible = false; + } + else + { + RebuildActiveList(); + RefreshActiveRaiseButtons(); + } + UpdateTabStates(); Console.WriteLine($"[CharacterStat] Tab click: {tab}"); } @@ -511,7 +547,7 @@ public static class CharacterStatController { RetailTabBinding.SetOpen(attributesTab, activeTab[0] == CharacterStatTab.Attributes); RetailTabBinding.SetOpen(skillsTab, activeTab[0] == CharacterStatTab.Skills); - RetailTabBinding.SetOpen(titlesTab, false); + RetailTabBinding.SetOpen(titlesTab, activeTab[0] == CharacterStatTab.Titles); } void RebuildActiveList() diff --git a/src/AcDream.App/UI/Layout/CharacterTitlesController.cs b/src/AcDream.App/UI/Layout/CharacterTitlesController.cs new file mode 100644 index 00000000..2e4a44a5 --- /dev/null +++ b/src/AcDream.App/UI/Layout/CharacterTitlesController.cs @@ -0,0 +1,341 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Runtime; + +namespace AcDream.App.UI.Layout; + +/// +/// Campaign CT slice CT3 (2026-08-24): binds the character window's Titles +/// page (LayoutDesc 0x2100002E, element 0x10000539 — +/// gmCharacterTitleUI) to CT2's +/// owner through the standard / +/// / classes only — no bespoke +/// widgets, matching every other social/options row-list page in this +/// codebase (, +/// , +/// ). +/// +/// +/// +/// Rows. AddTitleToList @0x0049A840 resolves each row's text +/// through CharacterTitleTable::GetCharacterTitleFromID (ported as +/// , CT2) and inserts it SORTED +/// (FindSortedInsertPosition @0x0049A760 — an ordinal string sort on +/// the resolved display text). This port rebuilds the full sorted row set on +/// every change () rather than performing a true +/// positional splice: has no insert-at-index +/// primitive, and no other consumer in this +/// codebase needs one either (Friends/Squelch/Fellowship/Allegiance/chargen +/// skills/the Options tabs all rebuild-on-change the same way) — the +/// resulting VISIBLE order is retail-exact even though the underlying +/// mechanism is "rebuild," not "splice." +/// +/// +/// Selection + highlight. Retail's InfoRegion::SetState +/// mechanism (confirmed for the sibling stat rows by CT1's SEALED VERDICT) +/// applies SetState(selected ? 6 : 1) directly to the row element — +/// state 6 is . The title row +/// template (0x10000536) authors that exact Highlight state +/// (0x06001AAF) alongside its DirectState background +/// (0x06004CCA), so this controller uses the row's own +/// — no synthesized color +/// swap, unlike pages whose row template lacks a state-based highlight. +/// +/// +/// The "Set as Display Title" button (0x10000535). +/// UpdateButtons @0x0049A500 (CORRECTED per the campaign plan's CT1 +/// fix round): Ghosted (state 0xD) UNLESS a row is SELECTED whose title id +/// DIFFERS from the CURRENT display title; no selection is the Ghosted case, +/// not the enabled one. A click sends +/// CM_Social::Event_SetDisplayCharacterTitle (CT2's +/// command seam) — wire only, no +/// local mutation; the ghost gate itself makes an already-current selection +/// unreachable from the UI, so the click handler's own defensive re-check is +/// belt-and-braces, not the primary guard. +/// +/// +/// Display-title text (0x1000052F). Refresh @0x0049abc0 +/// shows the resolved current display title, or retail's hardcoded literal +/// "Unknown" when the id does not resolve — refreshed on BOTH +/// (retail's own +/// RecvNotice_UpdateCharacterTitleTable unconditionally calls +/// Refresh() on every 0x0029, CT2 review anchor 1) and +/// . +/// +/// +/// The two page captions (0x1000052E/0x10000531). Left +/// untouched by this controller — +/// already resolves every element's authored StringInfo caption at +/// import time (ResolveAuthoredString), the SAME mechanism every +/// other DAT-authored label in this window already relies on, so no +/// controller-side string lookup is needed or added here. +/// +/// +public sealed class CharacterTitlesController : IDisposable +{ + public const uint CurrentDisplayTitleTextId = 0x1000052Fu; + public const uint TitleListBoxId = 0x10000532u; + public const uint SetDisplayButtonId = 0x10000535u; + + /// The row template's own text child (0x10000536's + /// single Type-0xC child) — CT1 ground truth §3. + private const uint RowTextId = 0x10000537u; + + /// Retail's hardcoded fallback literal (Refresh + /// @0x0049abc0) for a display title id that does not resolve — + /// ported verbatim, not a StringTable key (CT2 review anchor 3). + private const string UnknownTitleText = "Unknown"; + + private readonly record struct Row(UiElement Root, uint TitleId); + + private readonly RuntimeCharacterTitleState _titles; + private readonly Func _resolveTitle; + private readonly Func _sendSetTitle; + private readonly UiTemplateListBox _listBox; + private readonly UiText? _displayText; + private readonly UiButton? _setDisplayButton; + private readonly List _rows = new(); + private uint? _selectedTitleId; + private bool _disposed; + + private CharacterTitlesController( + RuntimeCharacterTitleState titles, + Func resolveTitle, + Func sendSetTitle, + UiTemplateListBox listBox, + UiText? displayText, + UiButton? setDisplayButton) + { + _titles = titles; + _resolveTitle = resolveTitle; + _sendSetTitle = sendSetTitle; + _listBox = listBox; + _displayText = displayText; + _setDisplayButton = setDisplayButton; + } + + /// + /// Binds the Titles page's list box, scrollbar, display-title text, and + /// Set-as-Display button under (the + /// character window's imported tree — the Titles page's element ids are + /// unique client-wide, so no page-scoped search is needed, unlike the + /// multi-tab social panel's row families). Returns null (logging why) + /// when the list box itself is missing — every other element is + /// optional so a partial import still gets what it can. + /// + /// CT2's + /// method group in production; a delegate (not the concrete DAT-backed + /// class) so this controller stays hermetically testable without a live + /// IDatReaderWriter. + public static CharacterTitlesController? Bind( + UiElement layoutRoot, + RuntimeCharacterTitleState titles, + Func resolveTitle, + Func templateResolver, + Func sendSetTitle) + { + ArgumentNullException.ThrowIfNull(layoutRoot); + ArgumentNullException.ThrowIfNull(titles); + ArgumentNullException.ThrowIfNull(resolveTitle); + ArgumentNullException.ThrowIfNull(templateResolver); + ArgumentNullException.ThrowIfNull(sendSetTitle); + + if (UiElement.FindDescendant(layoutRoot, TitleListBoxId) is not UiTemplateListBox listBox) + { + Console.WriteLine( + $"[D.2b] CharacterTitlesController: ListBox 0x{TitleListBoxId:X8} not " + + "found — the Titles page will not populate."); + return null; + } + listBox.TemplateResolver = templateResolver; + + uint scrollbarElementId = listBox.ScrollbarElementId; + UiElement? scrollbarElement = scrollbarElementId == 0 + ? null + : UiElement.FindDescendant(layoutRoot, scrollbarElementId); + if (scrollbarElement is UiScrollbar scrollbar) + scrollbar.Model = listBox.Scroll; + else + Console.WriteLine( + $"[D.2b] CharacterTitlesController: scrollbar 0x{scrollbarElementId:X8} " + + "not found — the Titles list will not scroll."); + + UiText? displayText = + UiElement.FindDescendant(layoutRoot, CurrentDisplayTitleTextId) as UiText; + UiButton? setDisplayButton = + UiElement.FindDescendant(layoutRoot, SetDisplayButtonId) as UiButton; + + var controller = new CharacterTitlesController( + titles, resolveTitle, sendSetTitle, listBox, displayText, setDisplayButton); + controller.WireButton(); + + titles.TableReplaced += controller.OnTableReplaced; + titles.TitleAdded += controller.OnTitleAdded; + titles.DisplayTitleChanged += controller.OnDisplayTitleChanged; + + controller.RebuildRows(); + controller.RefreshDisplayText(); + controller.RefreshButtonGhost(); + return controller; + } + + private void WireButton() + { + if (_setDisplayButton is null) return; + _setDisplayButton.OnClick = () => + { + // Belt-and-braces re-check (CT2 review anchor 2): retail's real + // guard is the Ghosted state itself — UiButton refuses to raise + // OnClick while !Enabled — so this branch is normally + // unreachable from a real click, but a direct-call test (or a + // stray event) must still send nothing while ghosted, and never + // wait for a confirmation ACE does not send when re-setting the + // already-current title. + if (_selectedTitleId is not uint id || id == _titles.DisplayTitleId) + return; + _sendSetTitle(id); + }; + } + + /// + /// 0x0029 CharacterTitle — retail's own Refresh() is + /// unconditional here (CT2 review anchor 1), and UnPack always + /// rebuilds mTitleList from scratch. + /// itself decides whether the current selection survives (it does when + /// the selected id is still earned in the new table). + /// + private void OnTableReplaced() + { + RebuildRows(); + RefreshDisplayText(); + RefreshButtonGhost(); + } + + /// + /// 0x002B UpdateTitle, add half — CT2's F1 fix already dedupes + /// this event to genuine new memberships only (a repeat add fires no + /// event at all), so every firing here is a real new row. + /// + private void OnTitleAdded(uint titleId) + { + RebuildRows(); + RefreshButtonGhost(); + } + + private void OnDisplayTitleChanged(uint titleId) + { + RefreshDisplayText(); + RefreshButtonGhost(); + } + + /// + /// Full sorted rebuild — see the class remarks for why this port + /// rebuilds rather than performing retail's literal single-row + /// positional insert. Preserves scroll position + /// (). The current + /// selection survives when the selected id is still present in the + /// rebuilt row set; otherwise it is cleared here so the Set-as-Display + /// button's ghost state can never desync from what is actually + /// highlighted (a selection pointing at a no-longer-visible row would + /// leave the button enabled with nothing shown selected). + /// + private void RebuildRows() + { + _listBox.FlushPreservingScroll(); + _rows.Clear(); + + // A3/CT2 doc warning: EarnedTitleIds allocates a fresh array per + // read — safe here (a UI refresh call site, not a per-frame poll). + var sorted = new List<(uint Id, string Text)>(); + foreach (uint id in _titles.EarnedTitleIds) + sorted.Add((id, _resolveTitle(id) ?? UnknownTitleText)); + // FindSortedInsertPosition @0x0049A760: ordinal string sort on the + // resolved display text. + sorted.Sort(static (a, b) => string.CompareOrdinal(a.Text, b.Text)); + + foreach ((uint id, string text) in sorted) + { + UiElement? row = _listBox.AddItemFromTemplateList(0); + if (row is null) continue; + + if (row is UiDatElement datRow) + { + // Generic Type-3 container fallback (DatWidgetFactory) — + // "generic decoration; behavioral widgets opt back in" (its + // own class doc). Same page-opt-in shape + // CharacterCreationSkillsPage uses for its selectable rows. + datRow.ClickThrough = false; + uint capturedId = id; + datRow.OnClick = () => SelectRow(capturedId); + } + + if (UiElement.FindDescendant(row, RowTextId) is UiText rowText) + { + string capturedText = text; + rowText.LinesProvider = () => [new UiText.Line(capturedText, Vector4.One)]; + } + + _rows.Add(new Row(row, id)); + } + + if (_selectedTitleId is uint selected && !_rows.Exists(r => r.TitleId == selected)) + _selectedTitleId = null; + + ApplyRowHighlights(); + } + + private void SelectRow(uint titleId) + { + if (_disposed) return; + _selectedTitleId = titleId; + ApplyRowHighlights(); + RefreshButtonGhost(); + } + + /// Retail InfoRegion::SetState(selected ? 6 : 1) — the + /// row's OWN authored Highlight/DirectState media swap, not a + /// synthesized color (see class remarks). + private void ApplyRowHighlights() + { + foreach (Row row in _rows) + { + if (row.Root is IUiDatStateful stateful) + { + stateful.TrySetRetailState( + row.TitleId == _selectedTitleId + ? UiButtonStateMachine.Highlight + : UiButtonStateMachine.Normal); + } + } + } + + private void RefreshDisplayText() + { + if (_displayText is null) return; + string text = _resolveTitle(_titles.DisplayTitleId) ?? UnknownTitleText; + _displayText.LinesProvider = () => [new UiText.Line(text, Vector4.One)]; + } + + /// + /// UpdateButtons @0x0049A500 (CORRECTED — campaign plan CT1 fix + /// round): Ghosted UNLESS a row is selected whose title id DIFFERS from + /// the current display title. No selection is the Ghosted case. + /// + private void RefreshButtonGhost() + { + if (_setDisplayButton is null) return; + bool shouldGhost = _selectedTitleId is not uint id || id == _titles.DisplayTitleId; + _setDisplayButton.TrySetRetailState( + shouldGhost ? UiButtonStateMachine.Ghosted : UiButtonStateMachine.Normal); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _titles.TableReplaced -= OnTableReplaced; + _titles.TitleAdded -= OnTitleAdded; + _titles.DisplayTitleChanged -= OnDisplayTitleChanged; + } +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index e743ce94..88ee83ac 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -156,7 +156,22 @@ public sealed record ToolbarRuntimeBindings( // vendor-owned split-exempt-seed predicate (research doc §C.1). Func IsVendorSplitExempt); -public sealed record CharacterRuntimeBindings(CharacterSheetProvider Provider); +/// The sheet + raise-request flow (pre-existing). +/// Campaign CT slice CT2's RuntimeCharacterTitleState +/// owner — CT3's binds +/// directly against it. +/// CT2's DAT id->string chain +/// (CharacterTitleTable::GetCharacterTitleFromID). Constructed once at +/// composition time — its own constructor does no DAT I/O. +/// Retail TitleSet (0x002C) — wire only, no +/// local mutation (CT2's IRuntimeCharacterCommands.SetTitle via the +/// late-bound game-runtime command seam, same shape as every fellowship/ +/// allegiance command above). +public sealed record CharacterRuntimeBindings( + CharacterSheetProvider Provider, + RuntimeCharacterTitleState Titles, + CharacterTitleResolver TitleResolver, + Func SendSetTitle); /// /// Campaign OP slice OP3 (2026-08-11): bindings the retail Options panel @@ -532,6 +547,7 @@ public sealed class RetailUiRuntime : IDisposable private CharacterManagementUiMountCoordinator? _characterManagementMount; private CharacterCreationUiMountCoordinator? _characterCreationMount; private IDisposable? _characterSheetSubscription; + private Layout.CharacterTitlesController? _characterTitlesController; private ResourceShutdownTransaction? _shutdown; private bool _disposed; @@ -4040,6 +4056,37 @@ public sealed class RetailUiRuntime : IDisposable currentSheet = provider.BuildSheet(); refreshRows(); }); + + // CT3 (2026-08-24): the Titles page's row template lives in a + // SEPARATE LayoutDesc (0x2100005E, element 0x10000536 — CT1 ground + // truth §3), reachable only through the targeted single-root + // ImportInfos overload — same caching resolver shape the social + // panel's Friends/Fellowship/Allegiance row families already use. + var titleRowTemplates = new Layout.RowTemplateResolver( + (layoutId, elementId) => LayoutImporter.ImportInfos( + _bindings.Assets.Dats, layoutId, elementId), + info => + { + var strings = new DatStringResolver(_bindings.Assets.Dats); + return LayoutImporter.Build( + info, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont, + _bindings.Assets.ResolveFont, + strings.Resolve).Root; + }); + UiElement? TitleTemplateResolver(uint templateLayoutId, uint templateElementId) + { + lock (_bindings.Assets.DatLock) + return titleRowTemplates.Resolve(templateLayoutId, templateElementId); + } + _characterTitlesController = Layout.CharacterTitlesController.Bind( + layout.Root, + _bindings.Character.Titles, + _bindings.Character.TitleResolver.Resolve, + TitleTemplateResolver, + _bindings.Character.SendSetTitle); + RetailWindowHandle handle = RetailWindowFrame.Mount( Host.Root, layout.Root, @@ -4799,6 +4846,7 @@ public sealed class RetailUiRuntime : IDisposable () => { _characterSheetSubscription?.Dispose(); + _characterTitlesController?.Dispose(); Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged; WindowLockPresentation.Dispose(); WindowOpacity.Dispose(); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 19ba6170..4b8b07eb 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -802,7 +802,10 @@ public class CharacterStatControllerTests Assert.Equal( new uint[] { 0x06005D93u, 0x06005D95u, 0x06005D97u }, skills.Children.Cast().Select(child => child.ActiveMedia().File)); - Assert.True(titles.ClickThrough); + // CT3 (2026-08-24): the Titles tab is no longer inert — it switches + // to a real page (previously AP-109's known gap). + Assert.False(titles.ClickThrough); + Assert.NotNull(titles.OnClick); } [Fact] @@ -826,6 +829,45 @@ public class CharacterStatControllerTests Assert.Equal(RetailUiStateIds.Open, Assert.IsAssignableFrom(child).ActiveRetailStateId)); } + /// + /// CT3 (2026-08-24): unlike Attributes/Skills (which share ONE mounted + /// page and only rebind its content), the Titles page (0x10000539) is a + /// genuinely separate, non-duplicated subtree (CT1 ground truth §3) that + /// must actually be shown/hidden on tab switch. + /// + [Fact] + public void TitlesTab_Click_ShowsTitlesPageAndHidesAttributesSkillsContent() + { + var layout = FixtureLoader.LoadCharacter(); + var titlesTab = Assert.IsType(layout.FindElement(CharacterStatController.TabTitlesId)); + var attributesTab = Assert.IsType(layout.FindElement(CharacterStatController.TabAttribId)); + var titlesPage = layout.FindElement(CharacterStatController.TitlesPageId); + var attributesPage = layout.FindElement(CharacterStatController.AttributesPageId); + Assert.NotNull(titlesPage); + Assert.NotNull(attributesPage); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter, + spriteResolve: id => (id, 16, 16)); + + Assert.True(attributesPage!.Visible); + Assert.False(titlesPage!.Visible); + + titlesTab.OnClick!(); + + Assert.True(titlesPage.Visible); + Assert.False(attributesPage.Visible); + Assert.Equal(RetailUiStateIds.Open, titlesTab.ActiveRetailStateId); + Assert.Equal(RetailUiStateIds.Closed, attributesTab.ActiveRetailStateId); + + // Switching back to Attributes restores the shared content page and + // re-hides Titles. + attributesTab.OnClick!(); + + Assert.True(attributesPage.Visible); + Assert.False(titlesPage.Visible); + Assert.Equal(RetailUiStateIds.Closed, titlesTab.ActiveRetailStateId); + } + // ── Affordability helpers (GetRaiseCost) ────────────────────────────────── [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs new file mode 100644 index 00000000..fa3dd684 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs @@ -0,0 +1,414 @@ +using System.Numerics; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Runtime; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign CT slice CT3: hermetic (no DAT, no live runtime) tests for +/// . Binding-seam coverage +/// (feedback_test_the_binding_seam.md) uses the REAL committed +/// character_2100002E.json fixture — verified (2026-08-24) to already +/// carry the whole Titles page subtree, including the ListBox's own +/// authored TemplateList entry pointing at (0x2100005E, +/// 0x10000536) — so every element this controller binds except the row +/// template ITSELF (a separate LayoutDesc CT1 could only reach via a live +/// DAT probe, with no committed fixture yet) comes from the real imported +/// tree, not a hand-built stand-in. +/// +public sealed class CharacterTitlesControllerTests +{ + // Row template ground truth (docs/research/2026-08-24-campaign-ct-dat- + // ground-truth.md §3): LayoutDesc 0x2100005E, element 0x10000536 — a + // 270x24 Type-3 container with DirectState/Highlight media and one + // Type-0xC text child (0x10000537). + private const uint RowTemplateLayoutId = 0x2100005Eu; + private const uint RowTemplateElementId = 0x10000536u; + private const uint RowTextId = 0x10000537u; + private const uint RowNormalSprite = 0x06004CCAu; + private const uint RowHighlightSprite = 0x06001AAFu; + + private static ElementInfo BuildRowTemplateInfo() + { + var info = new ElementInfo + { + Id = RowTemplateElementId, + Type = 3u, + Width = 270f, + Height = 24f, + }; + info.StateMedia[""] = (RowNormalSprite, 3); + info.StateMedia["Highlight"] = (RowHighlightSprite, 1); + info.Children.Add(new ElementInfo + { + Id = RowTextId, + Type = 0xCu, + Width = 270f, + Height = 24f, + HJustify = HJustify.Left, + FontColor = Vector4.One, + }); + return info; + } + + private static UiElement? FakeRowTemplateResolver(uint layoutId, uint elementId) + => LayoutImporter.Build(BuildRowTemplateInfo(), static _ => (0u, 0, 0), null).Root; + + private sealed class Harness + { + public required ImportedLayout Layout; + public required UiTemplateListBox ListBox; + public required UiText DisplayText; + public required UiButton SetDisplayButton; + public required RuntimeCharacterTitleState Titles; + public required Dictionary Names; + public required List SentTitleIds; + public required CharacterTitlesController Controller; + + public IReadOnlyList Rows => + ListBox.ViewportForTest?.Children ?? []; + + public string RowText(UiElement row) => + ((UiText)UiElement.FindDescendant(row, RowTextId)!).LinesProvider().Single().Text; + + public uint RowMedia(UiElement row) => + ((UiDatElement)row).ActiveMedia().File; + } + + // ── Binding seam ───────────────────────────────────────────────────── + + [Fact] + public void Bind_FindsEveryTitlesPageElement_InTheRealImportedFixture() + { + Harness h = BindWithEarnedTitles([], displayTitleId: 0u); + + Assert.NotNull(h.Controller); + Assert.Equal(CharacterTitlesController.TitleListBoxId, h.ListBox.DatElementId); + Assert.Equal(CharacterTitlesController.CurrentDisplayTitleTextId, h.DisplayText.DatElementId); + Assert.Equal(CharacterTitlesController.SetDisplayButtonId, h.SetDisplayButton.DatElementId); + // The authored scrollbar (0x10000533, the ListBox's own + // ScrollbarElementId) must actually be wired to the list's scroll + // model, not merely present. + var scrollbar = Assert.IsType( + h.Layout.FindElement(h.ListBox.ScrollbarElementId)); + Assert.Same(h.ListBox.Scroll, scrollbar.Model); + } + + [Fact] + public void Bind_MissingListBox_ReturnsNullWithoutThrowing() + { + var root = new UiPanel(); + var titles = new RuntimeCharacterTitleState(); + + CharacterTitlesController? controller = CharacterTitlesController.Bind( + root, + titles, + static _ => null, + FakeRowTemplateResolver, + static _ => new RuntimeCommandResult(RuntimeCommandStatus.Inactive, default)); + + Assert.Null(controller); + } + + [Fact] + public void RowTemplateResolver_ReceivesTheFixturesOwnAuthoredTemplateIds() + { + // The REAL fixture's ListBox (0x10000532) authors TemplateList = + // [(0x2100005E, 0x10000536)] (dat property 0x64) — this proves the + // controller's AddItemFromTemplateList(0) call actually reads that + // authored entry rather than a hardcoded pair of its own. + var seen = new List<(uint LayoutId, uint ElementId)>(); + UiElement? Recording(uint layoutId, uint elementId) + { + seen.Add((layoutId, elementId)); + return FakeRowTemplateResolver(layoutId, elementId); + } + + BindWithEarnedTitles( + [1u], displayTitleId: 0u, + names: new() { [1u] = "Adventurer" }, + rowResolver: Recording); + + (uint layoutId, uint elementId) = Assert.Single(seen); + Assert.Equal(RowTemplateLayoutId, layoutId); + Assert.Equal(RowTemplateElementId, elementId); + } + + private static Harness BindWithEarnedTitles( + IReadOnlyList earnedIds, + uint displayTitleId, + Dictionary? names = null, + Func? rowResolver = null) + { + ImportedLayout layout = FixtureLoader.LoadCharacter(); + var titles = new RuntimeCharacterTitleState(); + titles.ReplaceTable(displayTitleId, earnedIds.ToArray()); + Dictionary resolvedNames = names ?? new Dictionary(); + var sent = new List(); + + RuntimeCommandResult SendSetTitle(uint id) + { + sent.Add(id); + return new RuntimeCommandResult(RuntimeCommandStatus.Accepted, default); + } + + string? ResolveTitle(uint id) => + resolvedNames.TryGetValue(id, out string? name) ? name : null; + + CharacterTitlesController? controller = CharacterTitlesController.Bind( + layout.Root, + titles, + ResolveTitle, + rowResolver ?? FakeRowTemplateResolver, + SendSetTitle); + Assert.NotNull(controller); + + var listBox = Assert.IsType( + layout.FindElement(CharacterTitlesController.TitleListBoxId)); + var displayText = Assert.IsType( + layout.FindElement(CharacterTitlesController.CurrentDisplayTitleTextId)); + var button = Assert.IsType( + layout.FindElement(CharacterTitlesController.SetDisplayButtonId)); + + return new Harness + { + Layout = layout, + ListBox = listBox, + DisplayText = displayText, + SetDisplayButton = button, + Titles = titles, + Names = resolvedNames, + SentTitleIds = sent, + Controller = controller!, + }; + } + + // ── Row sort + content ────────────────────────────────────────────── + + [Fact] + public void Rows_AreSortedAlphabeticallyByResolvedTitleText() + { + Harness h = BindWithEarnedTitles( + [13u, 5u, 1u], + displayTitleId: 0u, + names: new() + { + [1u] = "Adventurer", + [5u] = "Life Mage", + [13u] = "War Mage", + }); + + Assert.Equal(3, h.Rows.Count); + Assert.Equal( + ["Adventurer", "Life Mage", "War Mage"], + h.Rows.Select(h.RowText)); + } + + [Fact] + public void Rows_UnresolvedTitle_ShowsRetailUnknownLiteral() + { + Harness h = BindWithEarnedTitles( + [99u], + displayTitleId: 0u, + names: []); + + UiElement row = Assert.Single(h.Rows); + Assert.Equal("Unknown", h.RowText(row)); + } + + // ── Selection + highlight ───────────────────────────────────────────── + + [Fact] + public void SelectingARow_AppliesHighlightMedia_AndDeselectsTheOthers() + { + Harness h = BindWithEarnedTitles( + [1u, 5u], + displayTitleId: 0u, + names: new() { [1u] = "Adventurer", [5u] = "Life Mage" }); + UiElement first = h.Rows[0]; + UiElement second = h.Rows[1]; + + ((UiDatElement)first).OnClick!(); + + Assert.Equal(RowHighlightSprite, h.RowMedia(first)); + Assert.Equal(RowNormalSprite, h.RowMedia(second)); + + ((UiDatElement)second).OnClick!(); + + Assert.Equal(RowNormalSprite, h.RowMedia(first)); + Assert.Equal(RowHighlightSprite, h.RowMedia(second)); + } + + // ── Ghost truth table (UpdateButtons @0x0049A500, CORRECTED direction) ─ + + [Fact] + public void Ghost_NoSelection_ButtonIsGhosted() + { + Harness h = BindWithEarnedTitles([1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer" }); + + Assert.False(h.SetDisplayButton.Enabled); + } + + [Fact] + public void Ghost_SelectedRowEqualsCurrentDisplayTitle_ButtonIsGhosted() + { + Harness h = BindWithEarnedTitles( + [1u, 5u], displayTitleId: 5u, + names: new() { [1u] = "Adventurer", [5u] = "Life Mage" }); + // Sorted order: Adventurer(1), Life Mage(5) — select the row whose + // id equals the current display title (5). + UiElement lifeMageRow = h.Rows.Single(r => h.RowText(r) == "Life Mage"); + + ((UiDatElement)lifeMageRow).OnClick!(); + + Assert.False(h.SetDisplayButton.Enabled); + } + + [Fact] + public void Ghost_SelectedRowDiffersFromCurrentDisplayTitle_ButtonIsNormal() + { + Harness h = BindWithEarnedTitles( + [1u, 5u], displayTitleId: 5u, + names: new() { [1u] = "Adventurer", [5u] = "Life Mage" }); + UiElement adventurerRow = h.Rows.Single(r => h.RowText(r) == "Adventurer"); + + ((UiDatElement)adventurerRow).OnClick!(); + + Assert.True(h.SetDisplayButton.Enabled); + } + + // ── Click -> SetTitle wire send ──────────────────────────────────────── + + [Fact] + public void ClickingSetDisplay_SendsExactlyOneSetTitleWithTheSelectedId_AndMutatesNothingLocally() + { + Harness h = BindWithEarnedTitles( + [1u, 13u], displayTitleId: 1u, + names: new() { [1u] = "Adventurer", [13u] = "War Mage" }); + UiElement warMageRow = h.Rows.Single(r => h.RowText(r) == "War Mage"); + ((UiDatElement)warMageRow).OnClick!(); + + h.SetDisplayButton.OnClick!(); + + Assert.Equal([13u], h.SentTitleIds); + // No optimistic local mutation — CT2's own contract (ACE sends no + // echo when re-setting the current title; the display title only + // ever changes from a DisplayTitleChanged event). + Assert.Equal(1u, h.Titles.DisplayTitleId); + Assert.Equal("Adventurer", h.DisplayText.LinesProvider().Single().Text); + } + + [Fact] + public void ClickingSetDisplay_WhileGhosted_SendsNothing() + { + Harness h = BindWithEarnedTitles([1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer" }); + + // No row selected -> Ghosted. A direct OnClick invocation bypasses + // UiButton's own Enabled-gated event routing, so this exercises the + // controller's own belt-and-braces re-check. + h.SetDisplayButton.OnClick!(); + + Assert.Empty(h.SentTitleIds); + } + + // ── Wire events ───────────────────────────────────────────────────── + + [Fact] + public void TableReplaced_RebuildsRows_AndClearsSelection() + { + Harness h = BindWithEarnedTitles( + [1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer", [13u] = "War Mage" }); + ((UiDatElement)h.Rows[0]).OnClick!(); + Assert.True(h.SetDisplayButton.Enabled); // selected, differs from display(0) + + h.Titles.ReplaceTable(0u, [13u]); + + Assert.Equal(["War Mage"], h.Rows.Select(h.RowText)); + // The previously-selected row no longer exists post-rebuild -> back + // to the no-selection Ghosted state. + Assert.False(h.SetDisplayButton.Enabled); + } + + [Fact] + public void TableReplaced_SelectedTitleStillEarned_KeepsSelectionHighlighted() + { + Harness h = BindWithEarnedTitles( + [1u, 5u], displayTitleId: 0u, + names: new() { [1u] = "Adventurer", [5u] = "Life Mage" }); + UiElement lifeMageRow = h.Rows.Single(r => h.RowText(r) == "Life Mage"); + ((UiDatElement)lifeMageRow).OnClick!(); + Assert.True(h.SetDisplayButton.Enabled); + + // A resend of the SAME table (retail's own Refresh() is + // unconditional — CT2 review anchor 1) must not silently desync the + // ghost state from the still-valid selection. + h.Titles.ReplaceTable(0u, [1u, 5u]); + + UiElement rebuiltLifeMageRow = h.Rows.Single(r => h.RowText(r) == "Life Mage"); + Assert.Equal(RowHighlightSprite, h.RowMedia(rebuiltLifeMageRow)); + Assert.True(h.SetDisplayButton.Enabled); + } + + [Fact] + public void TitleAdded_InsertsExactlyOneRow_PreservingExistingRowsInSortedOrder() + { + Harness h = BindWithEarnedTitles( + [1u], displayTitleId: 0u, + names: new() { [1u] = "Adventurer", [5u] = "Life Mage" }); + Assert.Single(h.Rows); + + h.Titles.ApplyUpdateTitle(5u, setAsDisplay: false); + + Assert.Equal(["Adventurer", "Life Mage"], h.Rows.Select(h.RowText)); + } + + [Fact] + public void DisplayTitleChanged_UpdatesTextAndReevaluatesGhost() + { + Harness h = BindWithEarnedTitles( + [1u, 5u], displayTitleId: 0u, + names: new() { [1u] = "Adventurer", [5u] = "Life Mage" }); + UiElement lifeMageRow = h.Rows.Single(r => h.RowText(r) == "Life Mage"); + ((UiDatElement)lifeMageRow).OnClick!(); + Assert.True(h.SetDisplayButton.Enabled); // selected(5) != display(0) + + // Simulates the server echo (0x002B UpdateTitle, setAsDisplay=true) + // that a real Set-as-Display send would eventually produce. + h.Titles.ApplyUpdateTitle(5u, setAsDisplay: true); + + Assert.Equal("Life Mage", h.DisplayText.LinesProvider().Single().Text); + // Selection now equals the (new) current display title -> Ghosted. + Assert.False(h.SetDisplayButton.Enabled); + } + + [Fact] + public void DisplayTitleText_UnresolvedId_ShowsRetailUnknownLiteral() + { + Harness h = BindWithEarnedTitles([1u], displayTitleId: 77u, names: new() { [1u] = "Adventurer" }); + + Assert.Equal("Unknown", h.DisplayText.LinesProvider().Single().Text); + } + + [Fact] + public void DisplayTitleText_NoDisplayTitleSet_ShowsRetailUnknownLiteral() + { + Harness h = BindWithEarnedTitles([1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer" }); + + Assert.Equal("Unknown", h.DisplayText.LinesProvider().Single().Text); + } + + // ── Lifecycle ─────────────────────────────────────────────────────── + + [Fact] + public void Dispose_UnsubscribesFromTitleEvents() + { + Harness h = BindWithEarnedTitles([1u], displayTitleId: 0u, names: new() { [1u] = "Adventurer" }); + + h.Controller.Dispose(); + + // Must not throw, and must not rebuild the (now-orphaned) rows. + h.Titles.ReplaceTable(0u, [1u, 5u]); + Assert.Single(h.Rows); + } +} From 4ea946257da388f79f8f6b78b612234ecc97fb3b Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 23:01:06 +0200 Subject: [PATCH 43/89] =?UTF-8?q?docs(CT):=20CT4=20literals=20PE-recovered?= =?UTF-8?q?=20=E2=80=94=20separators=20'=20',=20level=20'%d'=20/=20'=3F=3F?= =?UTF-8?q?=3F'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-24-character-panel-parity-campaign.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index 47f89c23..339c92a3 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -92,10 +92,12 @@ the composing function verbatim before writing a line of C#. heritage=Int 0xBC, 0)` ("Female Aluvian"); then, when `CharacterTitleTable::GetCharacterTitleFromID(m_titleID)` resolves, `AppendText(separator @data_794358)` + `AppendText(titleString)` → - "Female Aluvian War Mage". Recover the separator literal with the - PE-read recipe (`claude-memory/reference_pe_byte_decode.md`). + "Female Aluvian War Mage". PE-read RECOVERED (2026-08-24): + the separator `@data_794358` is a single space `" "`; the + allegiance-rank separator `@data_794098` is likewise `" "`. - **Level** (`0x1000023B`): `InqInt(0x19)` present → - `Formatted(@data_7a0184)`; absent → literal `@data_7b0f34`. + `Formatted(@data_7a0184)` = `"%d"`; absent → literal + `@data_7b0f34` = `"???"` (both PE-read recovered 2026-08-24). - **PK line** (`0x10000233`): `UpdatePKStatus @0x004F00A0` — three-way `IsPK` / `IsPKLite` / neither → StringInfo from table enum `0x10000001` (= StringTable `0x23000001`, same compute_str_hash From 4cc9448b0aa8df33dfc7631754765245c0d0c8e3 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 23:28:28 +0200 Subject: [PATCH 44/89] =?UTF-8?q?fix(CT):=20CT3=20fix=20round=20=E2=80=94?= =?UTF-8?q?=20port=20Refresh's=20unconditional=20selection=20clear,=20drop?= =?UTF-8?q?=20unresolvable-id=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus dual-lens review of CT3 (03e073b7) found 1 BLOCKER + 2 SHOULD-FIX + notes. BLOCKER: CharacterTitlesController never ported Refresh @0x0049abc0's own SetSelectedItem(nullptr, 1) (@0x0049ac5a) — retail clears the current title selection UNCONDITIONALLY on every Refresh() call, regardless of whether the previously-selected id is still earned. OnTableReplaced (0x0029) and OnDisplayTitleChanged (the display half of 0x002B) are retail's two Refresh() call sites, so both now clear _selectedTitleId before rebuilding/re-highlighting. OnTitleAdded (0x002B's add half) is a DIFFERENT retail method — RecvNotice_AddCharacterTitle @0x0049a990 splices one row without ever touching m_pSelectedItem — so it deliberately still preserves selection. Net effect: after the user sets a display title and ACE echoes 0x002B, the previously-highlighted row now goes dark and the Set-as-Display button re-ghosts, matching retail; earning a new title while a row is selected still leaves that selection alone. SHOULD-FIX: ported AddTitleToList @0x0049A840's early-outs (@0x0049a873/@0x0049a914) — an id of 0, or an id CharacterTitleResolver fails to resolve, now produces NO row at all. The "Unknown" fallback literal belongs only to the display-title text (Refresh @0x0049abc0's other half), never a row — this was previously ported backwards. SHOULD-FIX: rows and the display text now use their UiText's own authored DefaultColor instead of a hardcoded Vector4.One, and each LinesProvider now returns a cached UiText.Line[] built once per text change instead of allocating a fresh array literal every draw call (pattern: CharacterCreationSkillsPage.cs:829). Notes (all ruled in): corrected two CharacterStatController comments that falsely claimed the Titles page authors its own copies of the raise buttons (verified against the fixture — it does not; the hide loop that comment guarded is a defensive no-op given Visible's draw/click cascade, kept only for the contentPage-not-found fallback); switched the row sort from List.Sort to a stable OrderBy/ThenBy (ties broken by title id) so equal-text rows keep retail's insert-after-equals order; wrapped the title-resolver delegate in RetailUiRuntime.MountCharacter with the same DatLock the row-template resolver already takes (DatCollection is documented not thread-safe); set the list box's authored 24px row height so wheel/line scroll lands row-aligned; kept the bind-time display-text refresh with a comment explaining why the pre-notice "Unknown" frame is unreachable in live play (ACE always sends 0x0029 before this panel can open). Tests: inverted TableReplaced_SelectedTitleStillEarned_KeepsSelectionHighlighted into TableReplaced_ClearsSelection_EvenWhenTheSelectedIdIsStillEarned (cites @0x0049ac5a), added its DisplayTitleChanged twin, and added TitleAdded_PreservesSelection (the case most at risk from the blocker fix). Inverted Rows_UnresolvedTitle_ShowsRetailUnknownLiteral into Rows_UnresolvedTitle_ProducesNoRow (cites @0x0049a873/@0x0049a914) and added Rows_TitleIdZero_ProducesNoRow for the other early-out. Extended ClickingSetDisplay_..._AndMutatesNothingLocally to assert the row set and selection are untouched by the click. Added Fixture_PageCaptions_ResolveToNonEmptyText, which rebuilds the committed character_2100002E.json fixture with a stub string resolver to pin this class's own claim that the two page captions (0x1000052E/0x10000531) carry a resolvable authored StringInfo. Verified pre-existing/unrelated: the full hermetic suite run surfaced 2 failures in AcDream.App.Tests (LiveEntityNetworkBranchRoutingTests IL-shape assertion, GameWindowRenderLeafCompositionTests IL-shape assertion) that also fail with these five files stashed back to their pre-fix-round state — confirmed unrelated to this change. Build green. CharacterTitlesControllerTests: 24/24 (was 21, +3 net after one invert-and-split and two new facts). Full hermetic solution suite (Lane!=InstalledDat/PreparedPackage/Live/Manual/Timing/Windows/Linux/ SystemFont, Purpose!=Diagnostic, Status!=KnownFailure): only the two pre-existing IL-shape failures above; every other project green. Co-Authored-By: Claude Fable 5 --- ...6-08-24-character-panel-parity-campaign.md | 30 +++- .../UI/Layout/CharacterStatController.cs | 25 +++- .../UI/Layout/CharacterTitlesController.cs | 125 ++++++++++++++--- src/AcDream.App/UI/RetailUiRuntime.cs | 12 +- .../Layout/CharacterTitlesControllerTests.cs | 128 +++++++++++++++++- 5 files changed, 287 insertions(+), 33 deletions(-) diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index 339c92a3..06f0fe90 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -233,12 +233,40 @@ not yet acted on): to a wire arrival; the reverse still holds (every genuine new row has a `TitleAdded` firing). -**CT3 — Titles page UI.** Bind the authored page through the standard +**CT3 — Titles page UI. REVIEW-CLOSED 2026-08-24: landed `03e073b7`, +Opus dual-lens review (1 BLOCKER + 2 should-fix + notes, all applied +in the fix round below).** Bind the authored page through the standard GUI classes (`UiTemplateListBox`/`UiScrollbar`/`UiButton` — zero bespoke widgets): sorted rows via the ported title-table lookup, selection, ghost-when-current logic (state 0xd contract), display-title text, Set-as-Display round trip, scrollbar. Retires half of AP-109. +**CT3 fix round (Opus dual-lens review, 2026-08-24).** BLOCKER: ported +`Refresh @0x0049abc0`'s unconditional `SetSelectedItem(nullptr, 1)` +(`@0x0049ac5a`) — selection now clears on BOTH `TableReplaced` and +`DisplayTitleChanged`, regardless of whether the previously-selected id +is still earned in the new table, but deliberately survives +`TitleAdded` (`RecvNotice_AddCharacterTitle @0x0049a990` splices one +row without ever touching `m_pSelectedItem` — a genuinely different +retail method from `Refresh`). SHOULD-FIX: `AddTitleToList @0x0049A840`'s +early-outs (`@0x0049a873`/`@0x0049a914`) ported — an id of 0, or an id +`CharacterTitleResolver.Resolve` fails to resolve, now produces NO row +at all (the `"Unknown"` fallback literal belongs only to the +display-title text, never a row — this was previously ported +backwards); rows use the row template's own authored `DefaultColor` +instead of a hardcoded white, and each row/display-text `UiText.Line[]` +is built once per text change and cached instead of reallocated every +draw call. Notes also applied: corrected two comments that falsely +claimed the Titles page authors its own copies of the raise buttons +(verified against the fixture — it does not; the hide loop that +comment guarded is a defensive no-op, kept only for the +contentPage-not-found fallback path), switched the row sort from +`List.Sort` to a stable `OrderBy`/`ThenBy` (ties broken by title id), +wrapped the title-resolver delegate in the same `DatLock` the +row-template resolver already takes (`RetailUiRuntime.MountCharacter`), +and set the list box's authored 24px row height so wheel/line scroll +lands row-aligned. + **CT4 — Header identity block.** Retail composition: name; " "; PK status line — authored fonts/colors (pure white per probe), live refresh on display-title change and PK diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index 5ad9865b..4f50311d 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -374,8 +374,12 @@ public static class CharacterStatController // Mutable selected-index box: -1 = nothing selected. // Gather EVERY copy of the raise buttons in the tree. The raise button ids - // (0x10000246, 0x100005EB) appear once per tab page (Attributes/Skills/Titles) - // in the dat inheritance structure; ImportedLayout._byId keeps only the LAST + // (0x10000246, 0x100005EB) each appear TWICE under BOTH the Attributes page + // (0x1000022B) and the Skills page (0x1000022C) — once per footer-state group + // (0x10000247/0x10000241) — four copies total. Verified against the committed + // fixture (CT3 fix round): Titles (0x10000539) authors NO copies of its own — + // correcting this comment's earlier, false "once per tab page + // (Attributes/Skills/Titles)" claim. ImportedLayout._byId keeps only the LAST // mounted copy. We collect all copies so we can hide them all initially and // show/hide the correct set when a row is selected. // @@ -526,10 +530,19 @@ public static class CharacterStatController if (showTitles) { - // Titles authors its own (unused) copies of the raise buttons - // (CT1 ground truth); nothing on this page ever selects a stat - // row, so keep them hidden rather than rebuilding a list this - // tab does not show. + // CT3 fix round: the prior comment here ("Titles authors its + // own copies of the raise buttons") was FALSE — verified + // against the fixture, Titles (0x10000539) has none; see the + // corrected collection comment above. contentPage.Visible = + // false (just above) already suppresses the Attributes + // page's real raise-button copies for both draw and click + // routing (UiElement early-returns on an invisible node + // before descending to children), so this loop is a no-op + // in the common case. It is kept only as a defensive + // fallback for the case where contentPage was not found at + // bind time (contentPage is null, line ~525) but allRaise1/ + // allRaise10 were still populated via the tree-walk/FindElement + // fallback above. foreach (var b in allRaise1) b.Visible = false; foreach (var b in allRaise10) b.Visible = false; } diff --git a/src/AcDream.App/UI/Layout/CharacterTitlesController.cs b/src/AcDream.App/UI/Layout/CharacterTitlesController.cs index 2e4a44a5..77f86951 100644 --- a/src/AcDream.App/UI/Layout/CharacterTitlesController.cs +++ b/src/AcDream.App/UI/Layout/CharacterTitlesController.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using System.Numerics; +using System.Linq; using AcDream.Runtime; namespace AcDream.App.UI.Layout; @@ -29,7 +29,13 @@ namespace AcDream.App.UI.Layout; /// codebase needs one either (Friends/Squelch/Fellowship/Allegiance/chargen /// skills/the Options tabs all rebuild-on-change the same way) — the /// resulting VISIBLE order is retail-exact even though the underlying -/// mechanism is "rebuild," not "splice." +/// mechanism is "rebuild," not "splice." AddTitleToList itself +/// early-outs (@0x0049a873/@0x0049a914) before ever reaching +/// the insert — retail NEVER creates a row for id 0 or for an id +/// GetCharacterTitleFromID fails to resolve, so +/// skips those ids entirely rather than falling back to a placeholder row +/// text (CT3 fix round — the "Unknown" literal belongs ONLY to the +/// display-title text below, never to a row). /// /// /// Selection + highlight. Retail's InfoRegion::SetState @@ -43,6 +49,27 @@ namespace AcDream.App.UI.Layout; /// swap, unlike pages whose row template lacks a state-based highlight. /// /// +/// Selection lifetime (CT3 fix round — BLOCKER). Refresh +/// @0x0049abc0 itself calls SetSelectedItem(nullptr, 1) +/// (@0x0049ac5a) UNCONDITIONALLY, before it repopulates the list — +/// every code path that reaches Refresh() drops the current +/// selection outright, regardless of whether the previously-selected title +/// id is still earned. Refresh() runs on BOTH +/// (0x0029) +/// and (the +/// display-title half of 0x002B), so +/// and both clear +/// before rebuilding/re-highlighting. +/// RecvNotice_AddCharacterTitle @0x0049a990 (the add half of +/// 0x002B, ) is a DIFFERENT retail method +/// that splices one row into mTitleList without ever touching +/// m_pSelectedItem — selection SURVIVES a title add. Concretely: +/// after the user sets a display title and ACE echoes 0x002B, the +/// previously-highlighted row goes dark and the Set-as-Display button +/// re-ghosts, exactly like retail — but earning a brand-new title while a +/// row is selected leaves that selection alone. +/// +/// /// The "Set as Display Title" button (0x10000535). /// UpdateButtons @0x0049A500 (CORRECTED per the campaign plan's CT1 /// fix round): Ghosted (state 0xD) UNLESS a row is SELECTED whose title id @@ -149,6 +176,12 @@ public sealed class CharacterTitlesController : IDisposable return null; } listBox.TemplateResolver = templateResolver; + // The row template (0x10000536) authors a 270x24 box (CT1 ground + // truth §3); UiTemplateListBox's own DefaultLineHeight is 16, which + // would desync wheel/line scroll from the actual row pitch + // (CharacterManagementUiController.cs:463 sets its own row height + // the same way for the same reason). + listBox.LineHeight = 24; uint scrollbarElementId = listBox.ScrollbarElementId; UiElement? scrollbarElement = scrollbarElementId == 0 @@ -175,6 +208,14 @@ public sealed class CharacterTitlesController : IDisposable titles.DisplayTitleChanged += controller.OnDisplayTitleChanged; controller.RebuildRows(); + // Bind-time refresh (CT3 fix round NOTE 6): retail itself only + // shows "Unknown" until the first notice arrives (nothing runs + // Refresh() before Refresh() is first called), but ACE always sends + // 0x0029 at SendSelf before this panel can even open, so the + // pre-notice "Unknown" frame is unreachable in live play. Refreshing + // at bind time instead keeps a window RE-mount (tab re-open, panel + // rebuild) consistent with whatever the table already holds, rather + // than flashing "Unknown" for one frame before the next notice. controller.RefreshDisplayText(); controller.RefreshButtonGhost(); return controller; @@ -201,12 +242,15 @@ public sealed class CharacterTitlesController : IDisposable /// /// 0x0029 CharacterTitle — retail's own Refresh() is /// unconditional here (CT2 review anchor 1), and UnPack always - /// rebuilds mTitleList from scratch. - /// itself decides whether the current selection survives (it does when - /// the selected id is still earned in the new table). + /// rebuilds mTitleList from scratch. BLOCKER fix (CT3 fix round): + /// Refresh also calls SetSelectedItem(nullptr, 1) + /// (@0x0049ac5a) unconditionally, BEFORE it repopulates — so the + /// selection is cleared here regardless of whether the previously + /// selected id is still earned, not merely dropped when it disappears. /// private void OnTableReplaced() { + ClearSelection(); RebuildRows(); RefreshDisplayText(); RefreshButtonGhost(); @@ -216,6 +260,10 @@ public sealed class CharacterTitlesController : IDisposable /// 0x002B UpdateTitle, add half — CT2's F1 fix already dedupes /// this event to genuine new memberships only (a repeat add fires no /// event at all), so every firing here is a real new row. + /// RecvNotice_AddCharacterTitle @0x0049a990 splices the one new + /// row into mTitleList without ever touching + /// m_pSelectedItem (unlike Refresh's unconditional + /// clear) — selection deliberately survives a title add. /// private void OnTitleAdded(uint titleId) { @@ -223,36 +271,68 @@ public sealed class CharacterTitlesController : IDisposable RefreshButtonGhost(); } + /// + /// 0x002B UpdateTitle, display half — this is the other trigger + /// for retail's Refresh() (CT2 review anchor 1), so it carries + /// the same unconditional SetSelectedItem(nullptr, 1) + /// (@0x0049ac5a) as . This handler + /// does not call (the row SET is unchanged — + /// only the display title moved), so it re-applies highlights directly + /// to actually dark out the previously-selected row. + /// private void OnDisplayTitleChanged(uint titleId) { + ClearSelection(); + ApplyRowHighlights(); RefreshDisplayText(); RefreshButtonGhost(); } + /// Refresh @0x0049abc0's SetSelectedItem(nullptr, + /// 1) (@0x0049ac5a) — clears the tracked selection only; the + /// caller is responsible for re-applying row highlights and the button + /// ghost state afterward. + private void ClearSelection() => _selectedTitleId = null; + /// /// Full sorted rebuild — see the class remarks for why this port /// rebuilds rather than performing retail's literal single-row /// positional insert. Preserves scroll position - /// (). The current - /// selection survives when the selected id is still present in the - /// rebuilt row set; otherwise it is cleared here so the Set-as-Display - /// button's ghost state can never desync from what is actually - /// highlighted (a selection pointing at a no-longer-visible row would - /// leave the button enabled with nothing shown selected). + /// (). Callers that + /// mirror retail's unconditional Refresh() selection clear + /// () call + /// themselves before this runs; the check below is a defensive + /// fallback for any other caller ( included) + /// so a selection can never point at a row that no longer exists. /// private void RebuildRows() { _listBox.FlushPreservingScroll(); _rows.Clear(); + // AddTitleToList @0x0049A840 early-outs (@0x0049a873/@0x0049a914): + // retail never creates a row for id 0 or for an id + // GetCharacterTitleFromID fails to resolve — "Unknown" is the + // display-title text's OWN fallback (RefreshDisplayText), never a + // row's (CT3 fix round — was previously ported backwards). // A3/CT2 doc warning: EarnedTitleIds allocates a fresh array per // read — safe here (a UI refresh call site, not a per-frame poll). - var sorted = new List<(uint Id, string Text)>(); + var candidates = new List<(uint Id, string Text)>(); foreach (uint id in _titles.EarnedTitleIds) - sorted.Add((id, _resolveTitle(id) ?? UnknownTitleText)); + { + if (id == 0) continue; + string? text = _resolveTitle(id); + if (text is null) continue; + candidates.Add((id, text)); + } // FindSortedInsertPosition @0x0049A760: ordinal string sort on the - // resolved display text. - sorted.Sort(static (a, b) => string.CompareOrdinal(a.Text, b.Text)); + // resolved display text. OrderBy is a STABLE sort (unlike + // List.Sort) so equal-text rows keep retail's insert-after- + // equals order; ties are broken by title id for full determinism. + List<(uint Id, string Text)> sorted = candidates + .OrderBy(static c => c.Text, StringComparer.Ordinal) + .ThenBy(static c => c.Id) + .ToList(); foreach ((uint id, string text) in sorted) { @@ -272,8 +352,14 @@ public sealed class CharacterTitlesController : IDisposable if (UiElement.FindDescendant(row, RowTextId) is UiText rowText) { - string capturedText = text; - rowText.LinesProvider = () => [new UiText.Line(capturedText, Vector4.One)]; + // Build the line array once per text change and capture it + // — LinesProvider runs every draw, so a `=> [new Line(...)]` + // literal would allocate a fresh array every frame + // (pattern: CharacterCreationSkillsPage.cs:829). DefaultColor + // is the row template's own authored font color, not a + // hardcoded white. + UiText.Line[] lines = [new UiText.Line(text, rowText.DefaultColor)]; + rowText.LinesProvider = () => lines; } _rows.Add(new Row(row, id)); @@ -314,7 +400,10 @@ public sealed class CharacterTitlesController : IDisposable { if (_displayText is null) return; string text = _resolveTitle(_titles.DisplayTitleId) ?? UnknownTitleText; - _displayText.LinesProvider = () => [new UiText.Line(text, Vector4.One)]; + // Cached array, authored color — same reasoning as the row text + // above (CT3 fix round). + UiText.Line[] lines = [new UiText.Line(text, _displayText.DefaultColor)]; + _displayText.LinesProvider = () => lines; } /// diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 88ee83ac..0bfa426a 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -4080,10 +4080,20 @@ public sealed class RetailUiRuntime : IDisposable lock (_bindings.Assets.DatLock) return titleRowTemplates.Resolve(templateLayoutId, templateElementId); } + // CT3 fix round: CharacterTitleResolver.Resolve reads the SAME + // IDatReaderWriter (EnumMapper + StringTable lookups) as the row + // template resolver just above — DatCollection is documented not + // thread-safe, so this delegate needs the identical DatLock scope, + // not just the template resolver. + string? TitleResolver(uint titleId) + { + lock (_bindings.Assets.DatLock) + return _bindings.Character.TitleResolver.Resolve(titleId); + } _characterTitlesController = Layout.CharacterTitlesController.Bind( layout.Root, _bindings.Character.Titles, - _bindings.Character.TitleResolver.Resolve, + TitleResolver, TitleTemplateResolver, _bindings.Character.SendSetTitle); diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs index fa3dd684..122d24b2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs @@ -95,6 +95,37 @@ public sealed class CharacterTitlesControllerTests Assert.Same(h.ListBox.Scroll, scrollbar.Model); } + [Fact] + public void Fixture_PageCaptions_ResolveToNonEmptyText() + { + // Pins this class's own remarks claim (CT3 fix round item 5b): + // LayoutImporter.BuildText already resolves every element's + // authored StringInfo caption at import time, so the Titles page's + // two static captions (0x1000052E/0x10000531) must actually carry a + // resolvable authored StringInfo -- not silently come through as + // empty/missing text -- even though this controller never touches + // either element itself. FixtureLoader.LoadCharacter() passes NO + // string resolver (it needs no live DAT for structural conformance + // checks elsewhere), so this test rebuilds the SAME committed + // fixture with a stub resolver that stands in for + // DatStringResolver.Resolve -- exercising the real + // ResolveAuthoredString → stringResolve pipeline + // (DatWidgetFactory.cs) without needing a live StringTable. + static string? StubResolve(UiStringInfoValue info) => + info.TableId != 0u && info.StringId != 0u ? "" : null; + ImportedLayout layout = LayoutImporter.Build( + FixtureLoader.LoadCharacterInfos(), + static _ => (0u, 0, 0), + null, + stringResolve: StubResolve); + + var currentTitleCaption = Assert.IsType(layout.FindElement(0x1000052Eu)); + var titlesEarnedCaption = Assert.IsType(layout.FindElement(0x10000531u)); + + Assert.Equal("", currentTitleCaption.LinesProvider()[0].Text); + Assert.Equal("", titlesEarnedCaption.LinesProvider()[0].Text); + } + [Fact] public void Bind_MissingListBox_ReturnsNullWithoutThrowing() { @@ -206,15 +237,34 @@ public sealed class CharacterTitlesControllerTests } [Fact] - public void Rows_UnresolvedTitle_ShowsRetailUnknownLiteral() + public void Rows_UnresolvedTitle_ProducesNoRow() { + // AddTitleToList @0x0049A840 early-outs (@0x0049a873/@0x0049a914): + // retail never creates a row for an id GetCharacterTitleFromID + // fails to resolve -- "Unknown" is exclusively the display-title + // text's own Refresh fallback literal (below), never a row's + // (CT3 fix round -- this was previously ported backwards). Harness h = BindWithEarnedTitles( [99u], displayTitleId: 0u, names: []); + Assert.Empty(h.Rows); + } + + [Fact] + public void Rows_TitleIdZero_ProducesNoRow() + { + // Same early-out (@0x0049a873), the OTHER guarded case: retail + // never creates a row for id 0 even if a resolver were somehow + // willing to answer for it. + Harness h = BindWithEarnedTitles( + [0u, 1u], + displayTitleId: 0u, + names: new() { [0u] = "Should Never Appear", [1u] = "Adventurer" }); + UiElement row = Assert.Single(h.Rows); - Assert.Equal("Unknown", h.RowText(row)); + Assert.Equal("Adventurer", h.RowText(row)); } // ── Selection + highlight ───────────────────────────────────────────── @@ -288,6 +338,7 @@ public sealed class CharacterTitlesControllerTests names: new() { [1u] = "Adventurer", [13u] = "War Mage" }); UiElement warMageRow = h.Rows.Single(r => h.RowText(r) == "War Mage"); ((UiDatElement)warMageRow).OnClick!(); + List rowsBeforeClick = h.Rows.ToList(); h.SetDisplayButton.OnClick!(); @@ -297,6 +348,12 @@ public sealed class CharacterTitlesControllerTests // ever changes from a DisplayTitleChanged event). Assert.Equal(1u, h.Titles.DisplayTitleId); Assert.Equal("Adventurer", h.DisplayText.LinesProvider().Single().Text); + // The click is wire-only: no RebuildRows, no selection change. The + // row set is the SAME instances in the same order, and War Mage + // stays selected/highlighted/enabled exactly as before the click. + Assert.Equal(rowsBeforeClick, h.Rows); + Assert.Equal(RowHighlightSprite, h.RowMedia(warMageRow)); + Assert.True(h.SetDisplayButton.Enabled); } [Fact] @@ -331,8 +388,14 @@ public sealed class CharacterTitlesControllerTests } [Fact] - public void TableReplaced_SelectedTitleStillEarned_KeepsSelectionHighlighted() + public void TableReplaced_ClearsSelection_EvenWhenTheSelectedIdIsStillEarned() { + // BLOCKER fix (CT3 fix round): Refresh @0x0049abc0 calls + // SetSelectedItem(nullptr, 1) (@0x0049ac5a) UNCONDITIONALLY, before + // it repopulates the list -- a still-earned selected id is no + // defense. A byte-identical resend must still dark out the row and + // re-ghost the button (this test used to assert the OPPOSITE and + // was wrong). Harness h = BindWithEarnedTitles( [1u, 5u], displayTitleId: 0u, names: new() { [1u] = "Adventurer", [5u] = "Life Mage" }); @@ -340,13 +403,64 @@ public sealed class CharacterTitlesControllerTests ((UiDatElement)lifeMageRow).OnClick!(); Assert.True(h.SetDisplayButton.Enabled); - // A resend of the SAME table (retail's own Refresh() is - // unconditional — CT2 review anchor 1) must not silently desync the - // ghost state from the still-valid selection. + // Same table resent (retail's own Refresh() is unconditional — + // CT2 review anchor 1) — the id (5) is STILL earned afterward, yet + // the selection must still be dropped. h.Titles.ReplaceTable(0u, [1u, 5u]); UiElement rebuiltLifeMageRow = h.Rows.Single(r => h.RowText(r) == "Life Mage"); - Assert.Equal(RowHighlightSprite, h.RowMedia(rebuiltLifeMageRow)); + Assert.Equal(RowNormalSprite, h.RowMedia(rebuiltLifeMageRow)); + Assert.False(h.SetDisplayButton.Enabled); + } + + [Fact] + public void DisplayTitleChanged_ClearsSelection_EvenWhenTheSelectedIdIsStillEarned() + { + // The display-change twin of the TableReplaced test above: 0x002B's + // display half is retail's OTHER Refresh() call site, so it carries + // the same unconditional SetSelectedItem(nullptr, 1) (@0x0049ac5a). + // This handler never calls RebuildRows (the row SET does not + // change), so it specifically proves the highlight is re-applied + // via ApplyRowHighlights even without a rebuild. Selecting a + // DIFFERENT id than the one becoming the new display title isolates + // this from the already-covered "selection == new display title" + // ghost case (DisplayTitleChanged_UpdatesTextAndReevaluatesGhost): + // id 1 remains earned and still differs from the new display id 5, + // yet selection must still clear. + Harness h = BindWithEarnedTitles( + [1u, 5u], displayTitleId: 0u, + names: new() { [1u] = "Adventurer", [5u] = "Life Mage" }); + UiElement adventurerRow = h.Rows.Single(r => h.RowText(r) == "Adventurer"); + ((UiDatElement)adventurerRow).OnClick!(); + Assert.Equal(RowHighlightSprite, h.RowMedia(adventurerRow)); + Assert.True(h.SetDisplayButton.Enabled); // selected(1) != display(0) + + h.Titles.ApplyUpdateTitle(5u, setAsDisplay: true); + + Assert.Equal(RowNormalSprite, h.RowMedia(adventurerRow)); + Assert.False(h.SetDisplayButton.Enabled); + } + + [Fact] + public void TitleAdded_PreservesSelection() + { + // RecvNotice_AddCharacterTitle @0x0049a990 splices the new row into + // mTitleList without ever touching m_pSelectedItem -- a DIFFERENT + // retail method from Refresh, and the one case that must NOT clear + // selection. This is the case most at risk from the blocker fix + // above (it would be trivial to over-clear on every wire event). + Harness h = BindWithEarnedTitles( + [1u], displayTitleId: 0u, + names: new() { [1u] = "Adventurer", [13u] = "War Mage" }); + UiElement adventurerRow = Assert.Single(h.Rows); + ((UiDatElement)adventurerRow).OnClick!(); + Assert.Equal(RowHighlightSprite, h.RowMedia(adventurerRow)); + Assert.True(h.SetDisplayButton.Enabled); + + h.Titles.ApplyUpdateTitle(13u, setAsDisplay: false); + + UiElement rebuiltAdventurerRow = h.Rows.Single(r => h.RowText(r) == "Adventurer"); + Assert.Equal(RowHighlightSprite, h.RowMedia(rebuiltAdventurerRow)); Assert.True(h.SetDisplayButton.Enabled); } From aa8106d57abe81a65cad5afa3cbaeb790bf30097 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 24 Aug 2026 23:30:27 +0200 Subject: [PATCH 45/89] docs(CT): CT3 review-closed (fix round 4cc9448b) Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-24-character-panel-parity-campaign.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index 06f0fe90..6b4490b6 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -233,9 +233,7 @@ not yet acted on): to a wire arrival; the reverse still holds (every genuine new row has a `TitleAdded` firing). -**CT3 — Titles page UI. REVIEW-CLOSED 2026-08-24: landed `03e073b7`, -Opus dual-lens review (1 BLOCKER + 2 should-fix + notes, all applied -in the fix round below).** Bind the authored page through the standard +**CT3 — Titles page UI. REVIEW-CLOSED 2026-08-24: landed `03e073b7`, Opus review (1 blocker: selection-clear semantics; 2 should-fix), fix round `4cc9448b`; full hermetic suite re-verified green after a load-flake false alarm.** Bind the authored page through the standard GUI classes (`UiTemplateListBox`/`UiScrollbar`/`UiButton` — zero bespoke widgets): sorted rows via the ported title-table lookup, selection, ghost-when-current logic (state 0xd contract), display-title From ed652ed8addd24fd7b216549f7ebea5d8594f75c Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 00:02:11 +0200 Subject: [PATCH 46/89] =?UTF-8?q?feat(ui):=20Campaign=20CT=20slice=20CT4?= =?UTF-8?q?=20=E2=80=94=20header=20identity=20block=20retail-exact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retires the rest of AP-109's UI half: the character panel's Name/Heritage/ PkStatus/Level header identity block is now live and DAT-faithful on both Attributes and Skills pages (verified: CharacterStatController.Bind already scopes Label/LabelAuthoredColor to the ONE physically-visible page container, so both tabs share the same bound widgets). - Name/Heritage/PkStatus/Level switch from hand-picked Body/Gold runtime colors to the widget's own authored DefaultColor (LabelAuthoredColor) — CT1's live-DAT pin (HeaderElements_AuthorExpectedFontsAndColors) confirmed all four already carry the correct FontColor (white/white/white/pale-gold with Outline); the former "runtime color, dat carries none" comment was false. - PkStatus resolves through StringTable 0x23000001 by key (ID_StatManagement_Header_PKStatus_PK/_PKL/_NPK) with a bitwise IsPK/IsPKLite test (gmStatManagementUI::UpdatePKStatus @0x004F00A0) instead of the prior exact-equality switch, which silently dropped combined-flag PlayerKillerStatus values. Live-DAT-verified strings: "Player Killer" / "Player Killer Lite" / "Non-Player Killer" (new InstalledDat pin PkStatusKeys_ResolveExpectedAuthoredStrings). - Level shows "%d"-formatted InqInt(0x19) or the PE-recovered literal "???" when absent (CharacterSheet.Level is now int?). - Heritage line appends CT2/CT3's resolved RuntimeCharacterTitleState display title through CharacterTitleResolver, refreshing live on both TableReplaced (0x0029) and DisplayTitleChanged (0x002B) — CharacterSheetProvider's ChangeBinding now subscribes to both. - Name-line ruling: ships the PLAIN-NAME case only. Retail's allegiance rank-title prefix (AllegianceData::GetFullName @0x005B6950 -> AllegianceSystem::GetTitle @0x005B8DD0) needs a ~200-string, 22-function heritage x gender table (verbatim decomp literals, e.g. GetAluvianMaleTitle @0x005B7BC0's Yeoman/Baronet/.../High King) judged out of reasonable size for this slice. RuntimeAllegianceState already carries the local player's own rank; only the string table is missing. Registered, not silently omitted. - Luminance pair (0x100005C5/0x100005C6): CharacterSheet.AvailableLuminance/ MaximumLuminance (PropertyInt64 6/7) already flow generically through both the PlayerDescription snapshot and the live 0x02CF private-update parsers (no wiring gap). The retail show/hide gate (Level >= 200 && MaximumLuminance != 0, UpdateExperience @0x004F0A70) is wired and toggles Visible on both elements every sheet refresh; the exact caption/value text could not be recovered this slice (retail's SetText source resolves through a Binary-Ninja-mislabeled data pointer, not a StringTable key — a DAT string-table sweep found no match), so content stays unbound rather than guessed. - AP-109 narrowed accordingly (register row amended in the same commit). Tests: CharacterStatControllerTests (heritage composition + live title update, name stays plain, level int/"???" with authored — not constant — color across 3 cases, PK line shows resolved text in authored color across 3 statuses, luminance visibility across 5 level/luminance combinations) and CharacterSheetProviderTests (PK key-by-status resolution including a combined-flag case, no-resolver leaves PkStatus null, Level null-vs-present, title resolution + live refresh on both title events + unsubscribe-on- dispose, luminance Int64 read-through). Full hermetic solution suite green under Release (0 failures across all 14 test projects); InstalledDat pins green. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 2 +- ...6-08-24-character-panel-parity-campaign.md | 50 +++++- .../InteractionRetainedUiComposition.cs | 30 +++- .../UI/Layout/CharacterIdentityText.cs | 31 +++- src/AcDream.App/UI/Layout/CharacterSheet.cs | 25 ++- .../UI/Layout/CharacterSheetProvider.cs | 101 ++++++++++- .../UI/Layout/CharacterStatController.cs | 106 +++++++++-- .../UI/Layout/CharacterPanelLiveDatTests.cs | 29 ++++ .../UI/Layout/CharacterSheetProviderTests.cs | 164 ++++++++++++++++++ .../UI/Layout/CharacterStatControllerTests.cs | 140 +++++++++++++++ 10 files changed, 646 insertions(+), 32 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 56cb2c22..28f3d4a4 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -356,7 +356,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-105 | **PARTIAL 2026-07-13** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`), and both chat backends share typed client-command routing plus one retained `ChatVM` for reply state. Retained chat still lacks complete tab/filter/unread, social availability, incoming squelch enforcement, and focus-opacity behavior. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, command ownership, and outer maximize geometry work; later chat work consolidates the remaining presentation/filter state | Tabs are no-ops, squelched lines can still render, contextual social actions are absent, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods | | ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | | AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` | -| AP-109 | **NARROWED 2026-08-24 at Campaign CT slice CT3 — the Titles-page half is now LIVE.** `CharacterTitlesController` binds the Titles page (`gmCharacterTitleUI`, LayoutDesc `0x2100002E` element `0x10000539`) through the standard `UiTemplateListBox`/`UiScrollbar`/`UiButton` classes — no bespoke widgets: the earned-titles list sorted by resolved display string (`FindSortedInsertPosition @0x0049A760`), the current display-title text (`Refresh @0x0049abc0`, including its hardcoded `"Unknown"` fallback, refreshed on both the table-replace and display-change notices), row selection using the row template's own authored Highlight state (the same `InfoRegion::SetState(6)` mechanism CT1 confirmed for the stat rows), the "Set as Display Title" button's Ghosted-unless-a-differing-selection gate (`UpdateButtons @0x0049A500`, CORRECTED direction per the CT campaign plan's CT1 fix round — no selection is the Ghosted case), and the `TitleSet (0x002C)` wire send through CT2's `RuntimeCharacterTitleState`/`IRuntimeCharacterCommands.SetTitle` (no local mutation). What remains open: the header identity block's live displayed-title composition and the level-200 luminance fields (both CT4's job). | `src/AcDream.App/UI/Layout/CharacterTitlesController.cs`; `src/AcDream.App/UI/Layout/CharacterStatController.cs` (three-way Titles tab switch); `src/AcDream.App/UI/RetailUiRuntime.cs` (`MountCharacter`'s title row-template resolver + `CharacterRuntimeBindings`); `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted; the Titles page's binding SEAM (not just its layout/logic) is asserted by CT3's fixture tests | The header identity block (name / heritage+display-title / PK line) and the luminance fields remain unported until CT4 lands | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` | +| AP-109 | **NARROWED FURTHER 2026-08-24 at Campaign CT slice CT4 — the header identity block is now LIVE.** (CT3's Titles-page narrowing above still stands verbatim.) `CharacterStatController`'s Name/Heritage/PkStatus/Level labels now use `LabelAuthoredColor` (the widget's own DAT-set `DefaultColor`/Outline, matching CT1's live-DAT pin — the former hardcoded `Body`/`Gold` runtime constants are deleted); the heritage line appends CT2/CT3's resolved display title (`CharacterSheetProvider` now takes `RuntimeCharacterTitleState`/`CharacterTitleResolver.Resolve`, refreshing on both `TableReplaced` and `DisplayTitleChanged`); the PK line resolves through StringTable `0x23000001` by key (`ID_StatManagement_Header_PKStatus_PK`/`_PKL`/`_NPK`, DAT-verified strings "Player Killer"/"Player Killer Lite"/"Non-Player Killer") with a bitwise IsPK/IsPKLite test (retail `UpdatePKStatus @0x004F00A0`) instead of the prior exact-equality switch; the level shows `"%d"`-formatted `InqInt(0x19)` or the literal `"???"` when absent (both PE-recovered). Two items remain open, both registered rather than silently dropped: (1) the NAME line ships the PLAIN-NAME case only — retail's allegiance-rank prefix (`AllegianceData::GetFullName @0x005b6950` → `AllegianceSystem::GetTitle @0x005b8dd0`) needs a ~200-string, 22-function heritage×gender title table (verbatim in the decomp, e.g. `GetAluvianMaleTitle @0x005b7bc0`'s "Yeoman"/"Baronet"/.../"High King") that CT4 judged out of "reasonable size" for this slice — `RuntimeAllegianceState` already carries the local player's own rank (`_rank`/`ApplyUpdate`, seeded by `0x0020 AllegianceUpdate`), so only the string table is missing; (2) the luminance pair (`0x100005C5`/`0x100005C6`) has its DATA (`CharacterSheet.AvailableLuminance`/`MaximumLuminance`, retail PropertyInt64 6/7 — already flowing generically through both the PlayerDescription snapshot and the live `0x02CF` private-update parsers, no wiring gap) and its retail show/hide GATE (`Level >= 200 && MaximumLuminance != 0`, `UpdateExperience @0x004F0A70`) wired and toggling `Visible`, but no TEXT is bound — the label's caption and the value's composed "available/maximum" string both resolve in retail through a `SetText` call whose source string Binary Ninja mislabels as a vftable slot rather than a StringTable key, and a DAT string-table sweep this slice found no matching entry, so content stays blank pending a PE-byte-decode pass. | `src/AcDream.App/UI/Layout/CharacterStatController.cs` (`LabelAuthoredColor`, `RefreshLuminanceVisibility`); `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs`; `src/AcDream.App/UI/Layout/CharacterSheet.cs`; `src/AcDream.App/UI/Layout/CharacterIdentityText.cs`; `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs`; `src/AcDream.App/UI/Layout/CharacterTitlesController.cs` (Titles page, CT3, unchanged) | Attributes/skills core output and the Titles-page binding seam are user-accepted; CT4's header-identity binding-seam tests assert the composed heritage line, the three PK keys, the level integer/"???" fallback with authored (not constant) color, and the luminance show/hide rule against a real fixture, plus an InstalledDat pin for the three PK strings | A ranked-allegiance character's Name line shows plain name only (no title prefix) until the 22-function table is ported; a level-200+ character with luminance sees the pair correctly appear/disappear but with no caption or numbers until the exact retail string is recovered | `gmStatManagementUI::UpdateCharacterInfo @ 0x004F0770`; `UpdatePKStatus @ 0x004F00A0`; `UpdateExperience @ 0x004F0A70`; `AllegianceData::GetFullName @ 0x005B6950`; `AllegianceSystem::GetTitle @ 0x005B8DD0` | | AP-110 | **NARROWED 2026-08-09 (Slice 5.4, vendor browse panel) — "vendor" retired from the absent-panels list; see AP-161 for the precise successor (Buy/Sell transaction UI, Slice 6).** Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, inscription transaction, retail creature stat/rating/animated-preview presentation, default selection-follow, authored local spell subview with appropriate-formula component state, and the full EoR item-report dispatch: appraisal-only unknowns; exact equipment-set/rating/tinkering/weapon/armor/caster/requirement/XP/healer/rare prose and intentional blank section rows; ordinary/enchantment DAT spell descriptions; live material-decorated appropriate titles plus DAT material and creature names; expiry, decorated material/gem descriptions; and portal/PK restrictions with authored item colors. It still lacks item-object preview, player-dependent effective shield projection, live cooldown-remaining projection, localized augmentation-cost `StringInfo`, exhaustive character detail regions, and exact creature appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, the Slice 5.4 vendor "Items" browse panel (category-filtered stock list, retail's quantity-correct pricing — `ItemHolder::GetObjectSplitSize`'s split-exempt mask, not a flat per-unit price), and the core examination request/presentation/inscription/creature-preview/item-report loop cover the active loops; the residual examination mechanisms require live player/enchantment/localization state or object-preview ownership rather than fabricated content | Item assessments omit only the listed live/localized/preview projections; enchanted/incomplete creature appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `gmExaminationUI::ExamineSpell @ 0x004B6900`; `SpellExamineUI::ExamineSpell @ 0x004B6210`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ACCWeenieObject::GetObjectName @ 0x0058E6E0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::AddItemInfo @ 0x004AC050`; `ItemExamineUI::Appraisal_ShowCapacity @ 0x004B2680`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::Appraisal_ShowDescription @ 0x004B6990`; `MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog | | AP-161 | **REVIEW CORRECTIONS 2026-08-09 (Opus review of `92ea3977`, findings F1-F13):** thirteen further fixes, mostly bug-fixes-to-already-claimed-behavior rather than new divergences, so no new AP row is filed for most of them; the exceptions are called out below. F1 ports Buy All's four retail pre-send guards (pyreal/alt-currency affordability, container-slot/item-slot capacity) — see AP-162's narrowing. F2 corrects `AddToBuyList` from upsert to retail's actual ACCUMULATE-with-5000-cap semantics and ports `RemoveFromShop`'s shop-row hide/restore as staging consumes limited vendor supply. F3 corrects `VendorSellAcceptability`'s too-valuable branch to the byte-verified bitwise-complement form (`(~(itemTypeMask >> 16)) & 4`), exempting `PromissoryNote` items. F4 wires `BF_RETAINED` end to end, RETIRING AP-164 below. F5 ports `UpdateDragOver`'s auto-switch-to-Selling-on-hover. F6 corrects sell staging to always record the FULL stack (never the live split slider) and ports `SellSingleItem`'s partial-stack refusal plus its literal amount-1 send. F7 corrects the X-close confirmation string's missing trailing question mark. F8 disposes a live confirmation dialog on session Close/Reset. F9 repaints the Buying/Selling strips' own selection highlight on every selection change, not just a staging change. F10 unstages a sell entry that leaves `ClientObjectTable` and a buy entry whose shop row is retired, the latter with retail's exact notice. F11 reorders `RequestUse`'s eligibility check ahead of `BeginApproach` so an ineligible far target no longer speculatively approaches. F13 makes Sell Item act on the global selection unconditionally, matching retail — a prior version of this port required a staged entry first. New approximations this pass introduced are filed as AP-167 (`SellSingleItem`'s non-empty-container refusal branch not ported) and AP-168 (Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container`). **NARROWED 2026-08-09 (Slice 6b/6c, staging+sell arc) — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES.** `VendorUiController` now fully wires both tabs: Buying (`Add to List`/`Buy Item`/`Buy All`/`Clear Item`/`Clear List`, backed by `VendorStagingList`) and Selling (drag-to-sell via `IItemListDragHandler`, `VendorSellAcceptability`'s port of `VendorProfile::InqAcceptability`, `Sell Item`/`Sell All`/`Clear Item`/`Clear List`), plus the X-close staging confirmation dialog (`RetailDialogFactory`, the exact retail string recovered from the decompiled binary's data segment at `0x007b5bd8`). Sell (`0x0060`) is wired end to end (`VendorRequests.BuildSell`/`WorldSession.SendSell`/`ItemInteractionController.TrySell`). Three narrow residuals from this pass are filed separately rather than folded in here: `InqAcceptability`'s non-sellable bitfield check is unmodeled (AP-164), the Buying tab's stackable-removal-amount test substitutes `VendorShopItem.DescStackSize` for retail's `_maxStackSize` (AP-165), and the Buying/Selling tabs' own per-row/purse count text plus the cross-panel "pending sell" inventory highlight are not wired (AP-166). The two PRE-EXISTING residuals below (dropdown arrow-cap glyph, alt-currency `m_last_sale` simplification) are UNCHANGED by this pass — see the ORIGINAL text below for their citations. **REVIEW CORRECTIONS 2026-08-09 (Opus review of `97cf8738`, findings F1-F9):** none of these are NEW divergences from retail — they are bug fixes that make this row's own claims actually true, so no new AP row is filed for them. F2 fixed the priced/named quantity freezing at a selection-time seed while the Buy button separately read the LIVE slider — both now share one `ResolveBuyQuantity` computation, so the displayed price always equals what a purchase actually charges (retail: `gmVendorUI::RecvNotice_StackSliderChanged` re-runs the SAME display update on every slider change, `pc:203262-203278`). F6 corrected an unauthored "preserve the prior selection if it survives the filter" rule to retail's actual UNCONDITIONAL reselect-to-first-item on every rebuild this controller reaches (`VendorItemsUI::UpdateItemsList`'s notify=1 path, `pc:201180-201184`, confirmed reached by a fresh open AND a same-vendor refresh via `VendorItemsUI::OpenVendor`'s unconditional `SetSelectedItem(...,1)`, `pc:201022`). F7 ported `BuySingleItem`'s stack-size-1 quantity clamp (`pc:201674-201681`) so a stale slider value left over from a previously-selected, DIFFERENT stackable item cannot leak into a non-stack purchase. F8 is recorded inline below, where it corrects this row's own stale claim about the Add-to-List button. **NARROWED 2026-08-09 (Slice 6.1-6.3, buy arc) — TWO of the four consciously-deferred residuals below CLOSE.** Private per-panel selection is GONE: `SelectionState` gains a `Vendor` change source (`SelectionChangeSource.Vendor`) and is now the AUTHORITY — row clicks, the F4 auto-select-first-item fallback, and right-click examine all call `SelectionState.Select`/`Clear`; `VendorUiController` is a CONSUMER (`OnSelectionTransition`) exactly like every sibling panel, matching retail's global `ACCWeenieObject::selectedID`. The examine gap (F7c) is GONE too: `VendorShopItemMaterializer` (`src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs`, Slice 6.1) registers every `ApproachVendor` shop item into `ClientObjectTable` (guid, `ContainerId = vendorGuid`, merge-upserted via the ordinary `Ingest` path, retired on session Close/Reset/vendor-replace via the SAME `VendorState.Changed` subscription) so `AppraisalUiController.Apply`'s lookup now succeeds; `VendorUiController.ExamineItem` wires `UiItemList.ExamineItemRequested` to `ItemInteractionController.ExamineSelectedOrEnterMode`, mirroring `ExternalContainerController`. **Double-click-to-buy was investigated (research doc `docs/research/2026-08-08-slice6-vendor-transactions-research.md` §B.2) and confirmed ABSENT from retail** — no `gmVendorUI::CheckForDoubleClick`/`VendorItemsUI::CheckForDoubleClick` symbol exists anywhere in the 18,366-function named table, unlike sibling panels (`gmContractsUI::CheckForDoubleClick`, `gmPageListUI::CheckForDoubleClick`) that DO have one; acdream intentionally does NOT add a double-click shortcut — a user request for it as a deliberate acdream-only UX addition would need its own AP row, per CLAUDE.md's no-invented-mechanisms discipline. The remaining two residuals (dropdown arrow-cap glyph, alt-currency `m_last_sale` simplification) are UNCHANGED — see below. New approximations this pass introduced are filed separately as AP-162 (no client-side Buy pre-check) and AP-163 (shop-item guid-collision policy). **Original REWRITTEN text follows, retained for the two still-open residuals:** `VendorUiController` mounts LayoutDesc `0x21000012`/root `0x100000B7` and fully wires only the authored "Items" tab (`0x100000B9` — `VendorItemsUI`: category-filtered browse list with retail's quantity-correct pricing, `ItemHolder::GetObjectSplitSize`'s `0xDC41CB0` split-exempt mask ported locally rather than a flat per-unit price). The other two authored tabs render and switch pages (so the layout looks complete) but are otherwise INERT: "Buying" (`0x100000BA`, `VendorBuyUI` — staged-purchase review/confirm, buttons `0x100000C9`/`CA`/`CB`/`CC`) and "Selling" (`0x100000BB`, `VendorSellUI` — staged-sale review/confirm, buttons `0x100000D2`/`D3`/`D4`/`D5`) have no data binding at all. The "Items" page's own `Buy` button (`0x100000C2`) correctly enables/disables with selection (`UiButton.Enabled`, retail `SetState(1)`/`SetState(0xd)`) and Slice 6.3 wires it to a real immediate single-item purchase (`gmVendorUI::BuySingleItem`, `pc:201661` — `VendorRequests.BuildBuy`/`WorldSession.SendBuy`, opcode `0x005F`). **Review correction 2026-08-09 (F8):** `Add to List` (`0x100000C3`, staging) does NOT enable/disable with selection — it is PERMANENTLY disabled (`VendorUiController.SetActionButtonsEnabled`), because it has no wired `OnClick` at all; an enabled-but-dead button is a worse affordance than a disabled one, so it stays disabled until the "Buying" tab's staging list is actually implemented. The Buy opcode exists on the wire now; Sell (`0x0060`) does not. `VendorProfile::InqAcceptability` (sell-eligibility filtering) is unread — moot without a sell UI. Two divergences remain of the four the F1-F8 fix pass originally recorded — the other two (private per-panel selection, unwired shop-item examine) CLOSED at Slice 6.1/6.2, see the NARROWED note above: (1) the closed-dropdown button face reuses the row template's own two sprites (`0x060012B3` normal/`0x060012B4` open) through `UiMenu`'s existing single-texture 3-slice `DrawButtonFace` instead of retail's authored two-piece label+arrow-cap assembly (label `0x1000034D` + a separate 17x19 arrow cap `0x1000034E` with its own `0x060012B1`/`0x060012B2` states) — a cosmetic gap only; the popup panel and its rows render with the exact authored geometry and sprites; (2) the alt-currency "you have" holding reads `VendorShopProfile.AlternateCurrencyAmount` directly instead of tracking retail's `gmVendorUI.m_last_sale` purchase debit — moot until a sell path exists to actually debit it, since `m_last_sale` only changes on a completed SALE (retail's own `m_last_sale == 0` case, `pc:204091`/`OpenVendor`'s `this->m_last_sale = 0` reset at `pc:203790`/`203800`); Slice 6.3's buy path does not touch `m_last_sale` either (retail's own buy flow never writes it), so this residual is unaffected by the buy arc landing. The "Buying"/"Selling" staging tabs (`VendorBuyUI`/`VendorSellUI`) and the full Sell wire remain unwired — unchanged Slice 6b/6c territory per contract decision 6, not a residual of THIS row. | `src/AcDream.App/UI/Layout/VendorUiController.cs`; `src/AcDream.Core/Items/VendorState.cs`; `src/AcDream.Core.Net/GameEventWiring.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs` | Slice 6 (`docs/plans/2026-07-23-world-interaction-completion.md`) owns the authoritative buy/sell transaction command, quantity/stack-split selection, drag-to-sell consumption, and `InqAcceptability`-gated sell UI — Slice 5.4's charter was browse only. Buy (6.3), the global `ACCWeenieObject::selectedID` coupling (6.2), and shop-item `ClientObjectTable` registration (6.1) are now DONE, landing exactly the seam this row's original filing fenced off; drag-to-sell consumption and `InqAcceptability`-gated sell UI remain Slice 6b/6c territory. | A player can browse, select, examine, and BUY (Slice 6.3) — the only remaining unbuilt transaction is Sell. Clicking "Buying"/"Selling" still shows an empty page with no error or explanation, matching "present but does nothing" rather than a disabled/hidden affordance. The dropdown's closed-state button face is missing its separate arrow-cap glyph — a minor visual gap, not a functional one; the open popup itself is pixel-faithful to the authored template. | `gmVendorUI::OpenVendor` pc:203650 (`m_itemsUI`/`m_buyUI`/`m_sellUI` construction, `PostInit` pc:199906, `m_last_sale` reset pc:203790/203800); `VendorBuyUI::VendorBuyUI` pc:199717; `VendorSellUI::VendorSellUI` pc:199753; `VendorProfile::InqAcceptability` pc:484768-484797; `UIElement_Menu::MakePopup` pc:120705-120764, `::Initialize` pc:120789-120828; `VendorItemsUI::UpdateItemsUI` pc:202539-202820; `VendorItemsUI::UpdateItemsList` pc:201029-201190; `ItemHolder::GetObjectSplitSize` pc:401465-401477; `gmToolbarUI::HandleSelectionChanged` pc:198740-198790 (mask `0xDC41CB0` at pc:198784); `ACCWeenieObject::GetObjectName` pc:409056-409132; `docs/research/2026-08-08-slice5-vendor-browse-research.md` §B.4, §D | | AP-162 | **NARROWED 2026-08-09 (Opus review of `92ea3977`, finding F1) — the "Buy All" half of this row CLOSES.** `VendorUiController.BuyAllButtonPressed` now ports all four of retail's client-side pre-send guards (pyreal affordability `pc:204017`, alt-currency affordability `pc:204032`, container-slot capacity `pc:204053`, item-slot capacity `pc:204067`) — see `ComputeBuyTransactionValue`/`ComputeBuySlotsNeeded`/`CountPlayerContents`, each guard returning with staging fully intact and retail's own exact notice string (`"You don't have enough money"` at `0x007b57b4`, `"You must empty some slots in your backpack first"` at `0x007b5750`, both byte-recovered). The container-vs-item slot CLASSIFICATION this port uses (`ItemType.Container` instead of retail's bitfield/capacity test) is its own new, narrower approximation — filed separately as AP-168 rather than folded in here. Only `TryBuy`'s single-item Buy path (Items tab's own Buy button, and the Buying tab's "Buy Item") remains WITHOUT a client-side pre-check — the risk/oracle columns below now describe that one remaining case, not both. **EXTENDED 2026-08-09 (Slice 6b) — the same omission now also covers "Buy All".** `ItemInteractionController.TryBuyAll` (the batched-send path `VendorUiController.BuyAllButtonPressed` calls) sends unconditionally too, without porting retail's `pc:204017/204032/204053/204067` affordability/pack-capacity pre-checks for the MULTI-item case either — the same latency-not-correctness tradeoff this row already documents for the single-item path, extended rather than duplicated into a second row; retiring this row should port both the single- and batched-send pre-checks together. **Filed 2026-08-09, Slice 6.3 (buy wire + button).** Retail's `BuySingleItem` (`pc:201661`) performs TWO client-side pre-checks before ever sending `CM_Vendor::Event_Buy`: (a) an affordability check against `this->m_totalValue` (pyreal) or `shopVendorProfile->trade_num - m_last_sale` (alt-currency), showing a LOCAL string via `ECM_UI::SendNotice_DisplayStringInfo` and returning without sending anything on failure (`pc:201686-201717`); (b) a pack/container-capacity pre-check (`pc:201730-201746`) mirroring the server's own check. acdream's `ItemInteractionController.TryBuy` sends unconditionally once the shared use/inventory gate is free — no client-side affordability or capacity check runs before dispatch. Every refused purchase pays a full round-trip (send → server rejects → `UseDone`/`GameEventInventoryServerSaveFailed`) instead of failing instantly and silently client-side. **Swept 2026-08-09 (F4 review fix):** `TryBuy` now also checks whether `sendBuy` actually reached a live, in-world session before marking the reservation dispatched — an orthogonal reservation-leak bug fix (no session ever produced a stray permanent busy-lock), not an affordability/capacity check; this row's scope and residual are unchanged. | `src/AcDream.App/UI/ItemInteractionController.cs` (`TryBuy`) | The research doc's own open question 1 (`docs/research/2026-08-08-slice6-vendor-transactions-research.md`) recommends deferring this: the server is authoritative either way (ACE re-validates both affordability and capacity server-side — `Vendor.BuyItems_ValidateTransaction`, `Vendor.cs:431-571`), so omitting the client pre-check is a LATENCY/UX gap, not a correctness one — a refused purchase still fails cleanly, just one round-trip later than retail. | A player attempting to buy something they cannot afford or have no room for sees the failure arrive after a network round-trip instead of instantly; against a well-behaved ACE server no purchase can succeed that retail's pre-check would have blocked, so no transaction outcome differs — only its latency. Retiring this row means porting `BuySingleItem`'s two pre-check branches (`pc:201686-201746`) into `TryBuy` before dispatch. | `gmVendorUI::BuySingleItem` pc:201661/0x004C2820 (affordability pc:201686-201717, capacity pc:201730-201746); `Vendor.BuyItems_ValidateTransaction` (`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:431-571`); `docs/research/2026-08-08-slice6-vendor-transactions-research.md` §D point 4, Open question 1 | diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index 6b4490b6..0aa20007 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -265,12 +265,60 @@ row-template resolver already takes (`RetailUiRuntime.MountCharacter`), and set the list box's authored 24px row height so wheel/line scroll lands row-aligned. -**CT4 — Header identity block.** Retail composition: name; " +**CT4 — Header identity block. CODE-COMPLETE 2026-08-24.** Retail composition: name; " "; PK status line — authored fonts/colors (pure white per probe), live refresh on display-title change and PK status, identical on Attributes AND Skills pages. Level color from the authored element. Retires the rest of AP-109's UI half. +**CT4 landing notes (2026-08-24).** Verified the existing `Label(...)` seam +already covers both Attributes/Skills page copies — `CharacterStatController` +binds the SAME physically-visible container (contentPage = the Attributes +page chain) for both tabs; the Skills-page duplicate header subtree is never +shown (a test now pins this: `Bind_HeaderElements_UseVisibleAttributesPageWhenIdsAreDuplicated`). +All four header identity elements (Name/Heritage/PkStatus/Level) switched +from hand-picked `Body`/`Gold` runtime colors to the widget's own authored +`DefaultColor` (`LabelAuthoredColor`), matching CT1's live-DAT pin exactly — +the former "runtime color, dat carries none" comment was false. PK status now +resolves through StringTable `0x23000001` by key with a bitwise IsPK/IsPKLite +test (the prior exact-equality switch silently dropped combined-flag +values); **live-DAT-verified authored strings**: `ID_StatManagement_Header_PKStatus_PK` +→ "Player Killer", `_PKL` → "Player Killer Lite", `_NPK` → "Non-Player Killer" +(pinned in `CharacterPanelLiveDatTests.PkStatusKeys_ResolveExpectedAuthoredStrings`). +Level shows `"%d"`-formatted `InqInt(0x19)` or the PE-recovered literal +`"???"` when absent (`CharacterSheet.Level` is now `int?`). The heritage +line's appended title now comes from CT2/CT3's `RuntimeCharacterTitleState.DisplayTitleId` +resolved through `CharacterTitleResolver`, refreshing live on both +`TableReplaced` and `DisplayTitleChanged` (`CharacterSheetProvider`'s +`ChangeBinding` now subscribes to both). **Name-line ruling:** ships the +PLAIN-NAME case only — retail's allegiance rank-title prefix +(`AllegianceData::GetFullName @0x005b6950` → `AllegianceSystem::GetTitle +@0x005b8dd0`) needs a ~200-string, 22-function heritage×gender table +(verbatim hardcoded literals in the decomp, not DAT-resolved — e.g. +`GetAluvianMaleTitle @0x005b7bc0`'s "Yeoman"/"Baronet"/"Baron"/"Reeve"/ +"Thane"/"Ealdor"/"Duke"/"Aetheling"/"King"/"High King") judged out of +reasonable size for this slice; `RuntimeAllegianceState` already carries the +local player's own rank, so only the string table is missing. **Luminance +(item 5):** the DATA (`CharacterSheet.AvailableLuminance`/`MaximumLuminance`, +PropertyInt64 6/7) already flows generically through both the +PlayerDescription snapshot parser and the live `0x02CF` private-update path +— no wiring gap existed — and the retail show/hide gate +(`Level >= 200 && MaximumLuminance != 0`, `UpdateExperience @0x004F0A70`) is +wired and toggles `Visible` on both `0x100005C5`/`0x100005C6`, but the +label's caption and the value's composed number format could not be +recovered this slice (retail's `SetText` source resolves through a +Binary-Ninja-mislabeled data pointer, not a StringTable key; a DAT +string-table sweep found no match) — content stays unbound rather than +guessed. AP-109 narrowed accordingly (register row updated in the same +commit, not deleted — the two open items above remain). Tests: +`CharacterStatControllerTests` (heritage composition + live update, name +stays plain, level int/"???" with authored — not constant — color, PK line +shows resolved text in authored color, luminance visibility across five +level/luminance combinations) and `CharacterSheetProviderTests` (PK +key-by-status resolution including a combined-flag case, no-resolver ⇒ null, +Level null-vs-present, title resolution + live refresh on both title +events + unsubscribe-on-dispose, luminance Int64 read-through). + **CT5 — Row alignment + value gutter.** Reconcile our hand-built attribute/skill rows with the authored row templates from CT1: icon placement, name/value columns, the authored right margin that reserves diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 6bd8a257..c51a1f27 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -527,6 +527,19 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory var cursorManager = new RetailCursorManager(d.Dats, d.DatLock); checkpoint(InteractionRetainedUiCompositionPoint.CursorAssetsCreated); + // Campaign CT slice CT3 (2026-08-24): the Titles page's DAT + // id -> display-string chain (CT2). Constructed once — its own + // constructor does no DAT I/O (only .Resolve reads touch the + // dats), matching the characterCreationStrings precedent below. + // CT4 (2026-08-24) also feeds this resolver's display-title text + // into the character panel's own heritage line (CharacterSheet.Title). + var characterTitleResolver = new CharacterTitleResolver(d.Dats); + // CT4: the header identity block's PK-status line (StringTable + // 0x23000001, ID_StatManagement_Header_PKStatus_* keys — the same + // compute_str_hash mechanism ChatWindowController's chatStrings + // delegate already uses). One instance, same DatLock discipline + // as characterTitleResolver above. + var characterUiStrings = new DatStringResolver(d.Dats); var characterSheet = new CharacterSheetProvider( d.Inventory.Objects, d.Character.LocalPlayer, @@ -553,12 +566,17 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory late.GameRuntime.Advance( RuntimeAdvancementKind.TrainSkill, statId, - credits)); - // Campaign CT slice CT3 (2026-08-24): the Titles page's DAT - // id -> display-string chain (CT2). Constructed once — its own - // constructor does no DAT I/O (only .Resolve reads touch the - // dats), matching the characterCreationStrings precedent below. - var characterTitleResolver = new CharacterTitleResolver(d.Dats); + credits), + titles: d.Character.Titles, + resolveDisplayTitle: titleId => + { + lock (d.DatLock) return characterTitleResolver.Resolve(titleId); + }, + resolveUiString: key => + { + lock (d.DatLock) + return characterUiStrings.Resolve(0x23000001u, DatStringResolver.ComputeHash(key)); + }); checkpoint(InteractionRetainedUiCompositionPoint.CharacterSheetCreated); uint MagicSkillLevel(MagicSchool school) diff --git a/src/AcDream.App/UI/Layout/CharacterIdentityText.cs b/src/AcDream.App/UI/Layout/CharacterIdentityText.cs index 0b983ca8..f57a0d41 100644 --- a/src/AcDream.App/UI/Layout/CharacterIdentityText.cs +++ b/src/AcDream.App/UI/Layout/CharacterIdentityText.cs @@ -4,8 +4,37 @@ namespace AcDream.App.UI.Layout; /// Retail character identity display helpers for gmStatManagementUI. /// Sources: gmStatManagementUI::UpdateCharacterInfo (0x004f0770) calls /// AppraisalSystem::InqGenderHeritageDisplay(gender 0x71, heritage 0xBC, 0), -/// then appends the current CharacterTitleTable title when one is active. +/// then — when CharacterTitleTable::GetCharacterTitleFromID(m_titleID) +/// resolves — AppendText(separator @data_794358) + AppendText(titleString). +/// Campaign CT slice CT4 (2026-08-24) PE-read RECOVERED the separator as a +/// single space " " (both here and for the allegiance-rank prefix below); +/// 's existing string.Join(" ", ...) already +/// matched it. /// +/// +/// Name-line ruling (CT4, 2026-08-24). Retail's NAME line +/// (AllegianceData::GetFullName @0x005b6950) prefixes an allegiance +/// RANK title ("<RankTitle> <Name>", same space separator, PE-read +/// @data_794098) when AllegianceSystem::GetTitle(rank, heritage, gender) +/// @0x005b8dd0 resolves one. +/// (Campaign FA) DOES carry the local player's own rank +/// (ApplyUpdate's _rank, seeded by 0x0020 +/// AllegianceUpdate — always the local tree), so the DATA half exists. +/// The STRING half does not: GetTitle dispatches on heritage×gender +/// into 22 separate functions (GetAluvianMaleTitle @0x005b7bc0, +/// GetAluvianFemaleTitle @0x005b7cd0, … one per heritage/gender pair +/// through Undead), each a rank-indexed switch over ~10 HARDCODED literal +/// strings (Aluvian male: "Yeoman"/"Baronet"/"Baron"/"Reeve"/"Thane"/ +/// "Ealdor"/"Duke"/"Aetheling"/"King"/"High King" — verbatim from the +/// decomp, not DAT-resolved, not guessed) — roughly 200 title strings +/// total. That is not "reasonable size" for this slice on top of its other +/// four items, so 's Name label +/// ships the PLAIN-NAME case only (matching the owner's own retail +/// screenshot, a rankless character, and every current test character). +/// The missing rank-prefix path is registered +/// (docs/architecture/retail-divergence-register.md) rather than +/// silently omitted. +/// internal static class CharacterIdentityText { public const uint GenderPropertyId = 0x71u; diff --git a/src/AcDream.App/UI/Layout/CharacterSheet.cs b/src/AcDream.App/UI/Layout/CharacterSheet.cs index 43aece82..08c84b91 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheet.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheet.cs @@ -24,8 +24,15 @@ public sealed class CharacterSheet /// Character name (first line of the report). public string Name { get; init; } = string.Empty; - /// Character level. - public int Level { get; init; } + /// + /// Character level. Null when retail PropertyInt 0x19 is absent — Campaign + /// CT slice CT4: gmStatManagementUI::UpdateCharacterInfo + /// (0x004f0770) shows the literal "???" (data_7b0f34, PE-recovered) + /// in that case rather than an integer; a bare formatted level uses + /// "%d" semantics (data_7a0184) — see + /// 's binding. + /// + public int? Level { get; init; } /// Gender display string, e.g. "Female". Null = omit. public string? Gender { get; init; } @@ -58,6 +65,20 @@ public sealed class CharacterSheet /// 0x10000233 (m_pPKStatusText). Null = omit. public string? PkStatus { get; init; } + /// + /// Campaign CT slice CT4: available Luminance points (retail + /// PropertyInt64 6, AvailableLuminance). Header element + /// 0x100005C5/0x100005C6 pair — shown only past level 200 with a + /// nonzero + /// (gmStatManagementUI::UpdateExperience 0x004f0a70's luminance + /// branch). + /// + public long AvailableLuminance { get; init; } + + /// Retail PropertyInt64 7, MaximumLuminance. See + /// . + public long MaximumLuminance { get; init; } + // ── Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) ───────── /// Formatted birth date string (retail InqInt(0x62) → strftime). diff --git a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs index 5d73dba4..32b20f60 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using AcDream.App.Net; using AcDream.Core.Items; using AcDream.Core.Player; +using AcDream.Runtime.Gameplay; using DatReaderWriter; using AcDream.Content; @@ -48,6 +49,31 @@ public sealed class CharacterSheetProvider private readonly Action? _sendRaiseSkill; private readonly Action? _sendTrainSkill; + /// Campaign CT slice CT2's title owner — its + /// DisplayTitleId feeds the CT4 heritage-line composition. + /// Null (tests, no live session) leaves + /// null. + private readonly RuntimeCharacterTitleState? _titles; + + /// CT2's CharacterTitleTable::GetCharacterTitleFromID DAT + /// chain ( in production, + /// DatLock-wrapped by the host). Returns null (retail's hardcoded + /// "Unknown" substitution belongs to the Titles-page controller, + /// not the header — the header line simply omits an unresolved title) + /// when the id doesn't resolve. + private readonly Func? _resolveDisplayTitle; + + /// + /// Campaign CT slice CT4: retail StringInfo lookup through + /// StringTable 0x23000001 by key (ID_StatManagement_Header_PKStatus_* + /// — gmStatManagementUI::UpdatePKStatus 0x004f00a0), the same + /// compute_str_hash mechanism ChatWindowController's + /// chatStrings delegate uses. Null (tests) or a resolution miss + /// both leave null — no invented + /// English fallback for this specific line (CT4 contract). + /// + private readonly Func? _resolveUiString; + /// Portal SkillTable (0x0E000004) — set by the host once dats load. public DatReaderWriter.DBObjs.SkillTable? SkillTable { get; set; } @@ -64,7 +90,10 @@ public sealed class CharacterSheetProvider Action? sendRaiseAttribute = null, Action? sendRaiseVital = null, Action? sendRaiseSkill = null, - Action? sendTrainSkill = null) + Action? sendTrainSkill = null, + RuntimeCharacterTitleState? titles = null, + Func? resolveDisplayTitle = null, + Func? resolveUiString = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer)); @@ -76,6 +105,9 @@ public sealed class CharacterSheetProvider _sendRaiseVital = sendRaiseVital; _sendRaiseSkill = sendRaiseSkill; _sendTrainSkill = sendTrainSkill; + _titles = titles; + _resolveDisplayTitle = resolveDisplayTitle; + _resolveUiString = resolveUiString; } /// @@ -114,7 +146,14 @@ public sealed class CharacterSheetProvider return _fallbackSheet?.Invoke(CharacterName()) ?? new CharacterSheet { Name = CharacterName() }; var props = CurrentPlayerProperties(); + // #431/CT4: retail's own PropertyInt 0x19 read (InqInt) — the header + // level's "???" fallback (CharacterSheet.Level's own doc comment) + // needs to distinguish "absent" from "present but zero", so this + // stays a raw dictionary probe rather than GetInt's zero-defaulting + // helper. The XP-curve math below still wants a concrete int, so it + // keeps using the 0-defaulted local. int level = props.GetInt(0x19u); + int? displayLevel = props.Ints.ContainsKey(0x19u) ? level : null; long totalXp = props.GetInt64(1u); long unassignedXp = props.GetInt64(UnassignedXpPropertyId); var xp = ComputeLevelXp(level, totalXp); @@ -124,15 +163,28 @@ public sealed class CharacterSheetProvider return new CharacterSheet { Name = CharacterName(), - Level = level, + Level = displayLevel, Gender = CharacterIdentityText.GenderDisplayName( props.GetInt(CharacterIdentityText.GenderPropertyId)), Heritage = CharacterIdentityText.HeritageGroupDisplayName( props.GetInt(CharacterIdentityText.HeritageGroupPropertyId)), - PkStatus = PkStatusText(props.GetInt(134u, 0)), + // CT2/CT3's resolved display title — CT4's heritage line appends + // this (CharacterIdentityText.StatHeaderLine). + Title = _titles is not null && _resolveDisplayTitle is not null + ? _resolveDisplayTitle(_titles.DisplayTitleId) + : null, + PkStatus = PkStatusText(props.GetInt(134u, 0), _resolveUiString), TotalXp = totalXp, XpToNextLevel = xp.toNext, XpFraction = xp.fraction, + // CT4 item 5: retail PropertyInt64 6/7 — the private-update + // (0x02CF) and PlayerDescription (0x0013) parsers both already + // copy every Int64 key generically (ReadInt64Table / + // LocalPlayerState.OnInt64PropertyUpdate have no id whitelist), + // so ids 6/7 flow through with zero additional wiring once ACE + // sends them. + AvailableLuminance = props.GetInt64(6u), + MaximumLuminance = props.GetInt64(7u), HealthCurrent = VitalCurrent(LocalPlayerState.VitalKind.Health), HealthMax = VitalMax(LocalPlayerState.VitalKind.Health), @@ -243,8 +295,19 @@ public sealed class CharacterSheetProvider // not only on raw property/attribute updates. if (owner._localPlayer.Spellbook is { } spellbook) spellbook.EnchantmentsChanged += OnCleared; + // CT4 contract: the heritage line's appended display title MUST + // refresh live on BOTH RuntimeCharacterTitleState notices — CT2's + // TableReplaced (0x0029, retail's own unconditional Refresh()) and + // DisplayTitleChanged (the display half of 0x002B). + if (owner._titles is { } titles) + { + titles.TableReplaced += OnCleared; + titles.DisplayTitleChanged += OnDisplayTitleChanged; + } } + private void OnDisplayTitleChanged(uint _) => OnCleared(); + private void OnObjectChanged(ClientObject value) { CharacterSheetProvider? owner = _owner; @@ -300,6 +363,11 @@ public sealed class CharacterSheetProvider owner._localPlayer.Changed -= OnVitalChanged; if (owner._localPlayer.Spellbook is { } spellbook) spellbook.EnchantmentsChanged -= OnCleared; + if (owner._titles is { } titles) + { + titles.TableReplaced -= OnCleared; + titles.DisplayTitleChanged -= OnDisplayTitleChanged; + } // Panel unmount resets the one-in-flight raise gate — retail's // awaiting flag lives on the panel instance and dies with it. owner.ReleaseAwaitingRaise(); @@ -496,13 +564,28 @@ public sealed class CharacterSheetProvider private static long ClampToLong(ulong value) => value > long.MaxValue ? long.MaxValue : (long)value; - private static string? PkStatusText(int status) => status switch + /// + /// Campaign CT slice CT4: gmStatManagementUI::UpdatePKStatus + /// (0x004f00a0) — IsPK() tested first, then IsPKLite(), + /// else "neither" resolves the NPK string (retail always shows exactly + /// one of the three; there is no hidden/omitted case). ACE's + /// PlayerKillerStatus is a [Flags] enum (PK=0x04, + /// PKLite=0x40) — a bitwise test matches the derived-boolean retail + /// semantics; the prior exact-equality switch silently showed nothing + /// for any combined-flag value. Text resolves through StringTable + /// 0x23000001 by key () — no hardcoded + /// English fallback; a null resolver or a resolution miss both leave + /// the line empty, matching the CT4 contract's "no invented English". + /// + private static string? PkStatusText(int status, Func? resolveUiString) { - 0x2 => "Non-Player Killer", - 0x4 => "Player Killer", - 0x40 => "Player Killer Lite", - _ => null, - }; + string key = (status & 0x4) != 0 + ? "ID_StatManagement_Header_PKStatus_PK" + : (status & 0x40) != 0 + ? "ID_StatManagement_Header_PKStatus_PKL" + : "ID_StatManagement_Header_PKStatus_NPK"; + return resolveUiString?.Invoke(key); + } /// Unenchanted base attribute value (Ranks + Start). Used for /// — the retail diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index 4f50311d..8c62ddb4 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -70,6 +70,17 @@ public static class CharacterStatController public const uint ListScrollbarId = 0x1000023Eu; // m_pListBox vertical scrollbar gutter public const uint ListDividerId = 0x1000023Fu; // bottom divider above footer + /// Campaign CT slice CT4: the luminance pair + /// (m_pLuminanceLabelText/m_pLuminanceText), shown only past level 200 + /// with nonzero MaximumLuminance — see + /// gmStatManagementUI::UpdateExperience (0x004f0a70)'s luminance + /// branch. The label's own retail caption/value StringInfo could not be + /// recovered this slice (its SetText calls resolve through a + /// Binary-Ninja-mislabeled data pointer, not a StringTable key — see the + /// Bind method's own remarks); only the show/hide gate is wired here. + public const uint LuminanceLabelId = 0x100005C5u; + public const uint LuminanceValueId = 0x100005C6u; + // ── Footer STATE-A container id ────────────────────────────────────────── // 0x10000240 is the "nothing selected" footer group. Its children (0x1000024E label row, // 0x10000242–0x10000245 labels+values) are the correct State-A versions with wider @@ -111,7 +122,10 @@ public static class CharacterStatController public const uint RaiseTenId = 0x100005EBu; // raise × 10 private static readonly Vector4 Body = new(0.92f, 0.90f, 0.82f, 1f); // parchment-white body text - private static readonly Vector4 Gold = new(1f, 0.82f, 0.36f, 1f); // section / emphasis gold + // Campaign CT slice CT4 (2026-08-24): the former hand-picked "Gold" + // header-level color constant is deleted — CT1's live-DAT pin confirmed + // the level element authors its own pale-gold FontColor (+ Outline); + // LabelAuthoredColor reads it from the widget instead. /// Row highlight color — semi-translucent gold, matches retail /// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent. @@ -292,11 +306,22 @@ public static class CharacterStatController // Name (18px from dat FontDid), Heritage (14px), PkStatus (14px): // Fix C: pass null → Label's null-guard keeps the build-time dat font. - // Controllers still own the text color and the LinesProvider. - // Name = WHITE (retail "Horan" is white — confirmed 2026-06-26). - Label(layout, contentPage, NameId, null, Vector4.One, () => data().Name); - Label(layout, contentPage, HeritageId, null, Body, () => CharacterIdentityText.StatHeaderLine(data())); - Label(layout, contentPage, PkStatusId, null, Body, () => data().PkStatus ?? string.Empty); + // Campaign CT slice CT4 (2026-08-24): CT1's live-DAT pin + // (HeaderElements_AuthorExpectedFontsAndColors) confirmed all FOUR + // header identity elements — Name, Heritage, PkStatus, Level — carry + // their own authored FontColor (white/white/white/pale-gold with + // Outline). The "runtime color, dat carries none" reasoning this + // block used to justify a hand-picked Body/Gold constant per element + // was FALSIFIED by that pin: every element below now sources its + // color from the widget's own DAT-set DefaultColor + // (LabelAuthoredColor), matching the "authored color/font wins" + // pattern CT3's CharacterTitlesController already established for + // its row/display text. Level's Outline is likewise already applied + // at import time (DatWidgetFactory.BuildText reads dat property + // 0x21) — no controller-side Outline flag needed. + LabelAuthoredColor(layout, contentPage, NameId, null, () => data().Name); + LabelAuthoredColor(layout, contentPage, HeritageId, null, () => CharacterIdentityText.StatHeaderLine(data())); + LabelAuthoredColor(layout, contentPage, PkStatusId, null, () => data().PkStatus ?? string.Empty); // ── Header captions (new — retail labels above/left of each number) ────── // LevelCaption (0x1000023A, 16px from dat): pass null → keep build-time dat font. @@ -305,12 +330,13 @@ public static class CharacterStatController // Level number: retail renders this as large gold centered text in the 65×50 element. // Fix C: the dat FontDid for the level element (0x1000023B) is now applied at build // time when the font resolver is provided (studio path). We no longer force rowDatFont - // here for the level — the dat's own FontDid drives the font. The Gold color is still - // set via LinesProvider. SYNTHESIZED elements (the 9 attribute rows built in - // BuildAttributeRows) continue to use datFont directly since they have no dat origin. - // Source: spec §Level area (65,50) + decomp gmStatManagementUI::UpdateCharacterInfo 0x004f0770. - // runtime color, dat carries none. - Label(layout, contentPage, LevelId, null, Gold, () => data().Level.ToString()); + // here for the level — the dat's own FontDid drives the font. + // Source: spec §Level area (65,50) + decomp gmStatManagementUI::UpdateCharacterInfo + // 0x004f0770. CT4 contract (PE-recovered 2026-08-24): InqInt(0x19) present formats + // with "%d" semantics (a bare integer, data_7a0184); absent shows the literal "???" + // (data_7b0f34) — CharacterSheet.Level is null in that case. + LabelAuthoredColor(layout, contentPage, LevelId, null, + () => data().Level is int lvl ? lvl.ToString(CultureInfo.InvariantCulture) : "???"); // TotalXpLabel (16px from dat) + TotalXp (16px from dat): pass null → keep dat font. LabelLeft(layout, contentPage, TotalXpLabelId, null, Body, static () => "Total Experience (XP):"); @@ -367,6 +393,32 @@ public static class CharacterStatController } } + // ── Luminance pair (0x100005C5/C6) — CT4 item 5 ─────────────────────── + // gmStatManagementUI::UpdateExperience (0x004f0a70): InqInt64(6) + // (AvailableLuminance) and InqInt64(7) (MaximumLuminance) are read + // unconditionally, but the pair is hidden — UIElement_Text::ClearAllText + // on BOTH m_pLuminanceLabelText and m_pLuminanceText — whenever + // "InqInt(0x19) < 0xc8 (200) || MaximumLuminance == 0". Only the + // gate is ported this slice: the label's caption and the value's + // composed "available / maximum" string both resolve through a + // SetText call whose source string BN mislabels as a vftable slot + // (not a StringTable key like the PK line) — recovering the exact + // literal needs a PE-byte-decode pass this slice didn't budget for + // (register row: AP-109 narrows to exactly this). Content is + // intentionally left unbound (blank) rather than guessed; only + // Visible is toggled, so a level-200+ character sees an empty + // (not wrong) pair until a follow-up slice fills it in. + UiElement? luminanceLabel = FindElementByDatId(layout, contentPage, LuminanceLabelId); + UiElement? luminanceValue = FindElementByDatId(layout, contentPage, LuminanceValueId); + void RefreshLuminanceVisibility() + { + var sheet = data(); + bool visible = sheet.Level is int lvl && lvl >= 200 && sheet.MaximumLuminance != 0; + if (luminanceLabel is not null) luminanceLabel.Visible = visible; + if (luminanceValue is not null) luminanceValue.Visible = visible; + } + RefreshLuminanceVisibility(); + // The tab visuals are already retained in the imported LayoutDesc. Controllers // bind only click behavior and the active Open/Closed state below. @@ -656,6 +708,11 @@ public static class CharacterStatController } RefreshActiveRaiseButtons(); + // CT4: the luminance gate reads Level/MaximumLuminance off the + // CURRENT sheet, so it must re-run on every sheet-changed refresh + // (level-up, a luminance-award quality change), not only at bind + // time. + RefreshLuminanceVisibility(); } return () => RefreshAfterRaise(null); @@ -2009,6 +2066,31 @@ public static class CharacterStatController } } + /// + /// Same binding shape as , but the per-line color is + /// read from the widget's own — the + /// value DatWidgetFactory.BuildText already seeded from the + /// element's authored dat property 0x1B — instead of a caller-supplied + /// constant. Campaign CT slice CT4 (2026-08-24): the header identity + /// block's four elements (Name/Heritage/PkStatus/Level) all carry their + /// own correct authored color (CT1's live-DAT pin), so "authored color + /// wins" here is both simpler and more correct than hand-picking a + /// runtime constant — the same precedent + /// 's row/display text already + /// set (rowText.DefaultColor). + /// + private static void LabelAuthoredColor(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Func text) + { + if (FindTextByDatId(layout, scope, id) is UiText t) + { + if (datFont is not null) t.DatFont = datFont; + t.Centered = true; + t.OneLine = true; + t.ClickThrough = true; + t.LinesProvider = () => new[] { new UiText.Line(text(), t.DefaultColor) }; + } + } + /// Two-line centered label. Provides TWO lines from LinesProvider so both /// fit side-by-side in a narrow element without truncation. The scroll path in /// renders multiple lines oldest-first (top-to-bottom), so diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs index 65589b48..5a87693c 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs @@ -387,4 +387,33 @@ public sealed class CharacterPanelLiveDatTests string? resolved = resolver.Resolve(0x2300000Eu, hash); Assert.Equal("War Mage", resolved); } + + /// + /// Campaign CT slice CT4: gmStatManagementUI::UpdatePKStatus + /// (0x004f00a0) resolves its three-way PK status text through + /// StringTable 0x23000001 by key (the same compute_str_hash + /// mechanism ChatWindowController's chat labels already use — no + /// EnumMapper indirection needed here, unlike the title chain above). + /// Pins the exact authored strings CharacterSheetProvider.PkStatusText + /// resolves against, discovered by a live probe against the installed + /// DAT set (not guessed): "Player Killer" / "Player Killer Lite" / + /// "Non-Player Killer". + /// + [InstalledDatFact] + public void PkStatusKeys_ResolveExpectedAuthoredStrings() + { + using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + var resolver = new DatStringResolver(dats); + + string? pk = resolver.Resolve(0x23000001u, + DatStringResolver.ComputeHash("ID_StatManagement_Header_PKStatus_PK")); + string? pkLite = resolver.Resolve(0x23000001u, + DatStringResolver.ComputeHash("ID_StatManagement_Header_PKStatus_PKL")); + string? npk = resolver.Resolve(0x23000001u, + DatStringResolver.ComputeHash("ID_StatManagement_Header_PKStatus_NPK")); + + Assert.Equal("Player Killer", pk); + Assert.Equal("Player Killer Lite", pkLite); + Assert.Equal("Non-Player Killer", npk); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs index 1a8a42d7..94931d08 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs @@ -5,6 +5,7 @@ using AcDream.Core.Items; using AcDream.Core.Player; using AcDream.Core.Properties; using AcDream.Core.Spells; +using AcDream.Runtime.Gameplay; using Xunit; namespace AcDream.App.Tests.UI.Layout; @@ -420,4 +421,167 @@ public sealed class CharacterSheetProviderTests Assert.Equal(0, changed); } + + // ── Campaign CT slice CT4: PK status / display title / luminance ─────── + + /// + /// gmStatManagementUI::UpdatePKStatus (0x004f00a0): IsPK() tested + /// first, then IsPKLite(), else "neither" resolves NPK. The resolver + /// stub below echoes the KEY it was handed, so the assertion proves + /// which of the three ID_StatManagement_Header_PKStatus_* keys + /// was selected for each ACE PlayerKillerStatus value — the CT4 + /// contract's "PK line resolves the three keys by status". + /// + [Theory] + [InlineData(0x4, "ID_StatManagement_Header_PKStatus_PK")] + [InlineData(0x40, "ID_StatManagement_Header_PKStatus_PKL")] + [InlineData(0x2, "ID_StatManagement_Header_PKStatus_NPK")] // plain NPK bit + [InlineData(0x0, "ID_StatManagement_Header_PKStatus_NPK")] // Undef — still resolves NPK, not omitted + // Bitwise test (not the prior exact-equality switch): PK combined with + // an unrelated flag (Unprotected, 0x08) still resolves PK — IsPK() is + // true regardless of the other bits. + [InlineData(0x4 | 0x8, "ID_StatManagement_Header_PKStatus_PK")] + public void BuildSheet_PkStatus_ResolvesCorrectKeyByStatus(int rawStatus, string expectedKey) + { + var objects = new ClientObjectTable(); + var player = new LocalPlayerState(); + string? capturedKey = null; + var provider = new CharacterSheetProvider( + objects, player, + playerGuid: () => PlayerGuid, + resolveUiString: key => + { + capturedKey = key; + return key; // echo — the test asserts on the KEY, not invented English + }); + + var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" }; + obj.Properties.Ints[134u] = rawStatus; + objects.AddOrUpdate(obj); + + CharacterSheet sheet = provider.BuildSheet(); + + Assert.Equal(expectedKey, capturedKey); + Assert.Equal(expectedKey, sheet.PkStatus); + } + + /// CT4 contract: "no invented English; if a key fails to + /// resolve, show nothing" — a null resolver (no live DAT session, e.g. + /// the Studio path) must not synthesize any PK text. + [Fact] + public void BuildSheet_PkStatus_NoResolver_LeavesPkStatusNull() + { + var objects = new ClientObjectTable(); + var player = new LocalPlayerState(); + var provider = new CharacterSheetProvider(objects, player, playerGuid: () => PlayerGuid); + + var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" }; + obj.Properties.Ints[134u] = 0x4; // PK + objects.AddOrUpdate(obj); + + Assert.Null(provider.BuildSheet().PkStatus); + } + + /// CT4 contract item 4: Level is null (not 0) when retail + /// InqInt(0x19) is absent, distinguishing "no property yet" from a + /// genuinely-present value. + [Fact] + public void BuildSheet_Level_NullWhenPropertyAbsent_PresentOtherwise() + { + var objects = new ClientObjectTable(); + var player = new LocalPlayerState(); + var provider = new CharacterSheetProvider(objects, player, playerGuid: () => PlayerGuid); + + var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" }; + obj.Properties.Ints[0x18u] = 1; // some OTHER property present so HasLiveData() is true + objects.AddOrUpdate(obj); + Assert.Null(provider.BuildSheet().Level); + + obj.Properties.Ints[0x19u] = 42; + objects.AddOrUpdate(obj); + Assert.Equal(42, provider.BuildSheet().Level); + } + + /// + /// CT4 item 2: the heritage line's appended title comes from CT2's + /// resolved + /// through the DAT id->string chain — + /// tracks whatever the resolver returns for the CURRENT display id. + /// + [Fact] + public void BuildSheet_Title_ResolvesDisplayTitleIdThroughResolver() + { + var objects = new ClientObjectTable(); + var player = new LocalPlayerState(); + var titles = new RuntimeCharacterTitleState(); + var provider = new CharacterSheetProvider( + objects, player, + playerGuid: () => PlayerGuid, + titles: titles, + resolveDisplayTitle: id => id == 13u ? "War Mage" : null); + + var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" }; + obj.Properties.Ints[0x19u] = 1; // some property present so HasLiveData() is true + objects.AddOrUpdate(obj); + + Assert.Null(provider.BuildSheet().Title); // no display title seeded yet + + titles.ReplaceTable(13u, new uint[] { 13u }); + + Assert.Equal("War Mage", provider.BuildSheet().Title); + } + + /// + /// CT4 contract: the heritage line MUST refresh live on both + /// (0x0029) and + /// (the + /// display half of 0x002B) — both must fire the sheet-changed + /// notification + /// exposes, and both must stop firing after disposal. + /// + [Fact] + public void SubscribeChanged_FiresOnTitlesTableReplacedAndDisplayTitleChanged_AndUnsubscribesOnDispose() + { + var objects = new ClientObjectTable(); + var player = new LocalPlayerState(); + var titles = new RuntimeCharacterTitleState(); + var provider = new CharacterSheetProvider( + objects, player, playerGuid: () => PlayerGuid, titles: titles); + int changed = 0; + IDisposable subscription = provider.SubscribeChanged(() => changed++); + + titles.ReplaceTable(1u, new uint[] { 1u }); // TableReplaced (+ DisplayTitleChanged, id 0->1) + Assert.True(changed >= 1); + + int afterFirst = changed; + titles.ApplyUpdateTitle(2u, setAsDisplay: true); // UpdateTitle → DisplayTitleChanged (1->2) + Assert.True(changed > afterFirst); + + subscription.Dispose(); + int afterDispose = changed; + titles.ReplaceTable(3u, new uint[] { 3u }); + Assert.Equal(afterDispose, changed); + } + + /// CT4 item 5: retail PropertyInt64 6 (AvailableLuminance) / 7 + /// (MaximumLuminance) flow into the sheet exactly like TotalXp/ + /// UnassignedXp — the same generic, non-whitelisted Int64 property + /// path. + [Fact] + public void BuildSheet_Luminance_ReadsInt64Properties6And7() + { + var objects = new ClientObjectTable(); + var player = new LocalPlayerState(); + var provider = new CharacterSheetProvider(objects, player, playerGuid: () => PlayerGuid); + + var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" }; + obj.Properties.Int64s[6u] = 1_500_000L; + obj.Properties.Int64s[7u] = 25_000_000L; + objects.AddOrUpdate(obj); + + var sheet = provider.BuildSheet(); + + Assert.Equal(1_500_000L, sheet.AvailableLuminance); + Assert.Equal(25_000_000L, sheet.MaximumLuminance); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 4b8b07eb..5b0833e1 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -157,6 +157,146 @@ public class CharacterStatControllerTests Assert.Empty(hiddenXpNext.LinesProvider()); } + // ── Campaign CT slice CT4: header identity block ─────────────────────── + + /// CT4 item 2: the heritage line composes Gender + Heritage + + /// " " + the resolved display title (CharacterIdentityText.StatHeaderLine), + /// and — because the label's text() provider re-reads data() + /// on every draw — reflects a live title change with no rebind, exactly + /// how CharacterSheetProvider's own DisplayTitleChanged/ + /// TableReplaced subscription drives a real sheet rebuild in + /// production. + [Fact] + public void Bind_HeritageLine_ComposesGenderHeritageTitle_AndUpdatesLiveOnDisplayTitleChange() + { + var heritage = new UiText(); + var layout = Fake((CharacterStatController.HeritageId, heritage)); + CharacterSheet sheet = new() { Gender = "Female", Heritage = "Aluvian" }; + + CharacterStatController.Bind(layout, () => sheet); + + Assert.Equal("Female Aluvian", heritage.LinesProvider()[0].Text); + + // Simulates RuntimeCharacterTitleState.DisplayTitleChanged firing and + // CharacterSheetProvider rebuilding the sheet with the newly resolved + // title — CharacterStatController never rebinds, the label's own + // provider just re-reads the (reassigned) sheet. + sheet = new CharacterSheet { Gender = "Female", Heritage = "Aluvian", Title = "War Mage" }; + Assert.Equal("Female Aluvian War Mage", heritage.LinesProvider()[0].Text); + } + + /// CT4 item 1 ruling: the Name line ships the PLAIN-NAME case + /// only — retail's allegiance rank-title prefix + /// (AllegianceData::GetFullName @0x005b6950) needs a ~200-string + /// 22-function heritage×gender table judged out of reasonable size for + /// this slice (AP-109). The Name label must show exactly + /// , with no rank prefix synthesized + /// from anywhere. + [Fact] + public void Bind_NameLine_ShowsPlainNameOnly_NoRankPrefix() + { + var name = new UiText(); + var layout = Fake((CharacterStatController.NameId, name)); + + CharacterStatController.Bind(layout, () => new CharacterSheet { Name = "Dww" }); + + Assert.Equal("Dww", name.LinesProvider()[0].Text); + } + + /// CT4 item 4: the level shows a bare "%d"-formatted + /// integer when is present, and the + /// PE-recovered literal "???" when it is null (retail InqInt(0x19) + /// absent). Both cases use the WIDGET's own authored + /// — not a hardcoded constant — proving + /// the CT1 "authored color/font wins" fix (the former "Gold" constant is + /// deleted from the controller entirely). + [Theory] + [InlineData(126, "126")] + [InlineData(0, "0")] + [InlineData(null, "???")] + public void Bind_LevelLine_FormatsIntegerOrShowsQuestionMarks_InAuthoredColor(int? level, string expectedText) + { + var authoredColor = new Vector4(0.11f, 0.22f, 0.33f, 1f); + var levelText = new UiText { DefaultColor = authoredColor }; + var layout = Fake((CharacterStatController.LevelId, levelText)); + + CharacterStatController.Bind(layout, () => new CharacterSheet { Level = level }); + + UiText.Line line = Assert.Single(levelText.LinesProvider()); + Assert.Equal(expectedText, line.Text); + Assert.Equal(authoredColor, line.Color); + } + + /// CT4 item 3: the PK line shows exactly whatever + /// carries (the resolved + /// StringTable text — see CharacterSheetProviderTests for the + /// three-key resolution itself) in the widget's own authored color, not + /// the deleted parchment "Body" constant. + [Theory] + [InlineData("Player Killer")] + [InlineData("Player Killer Lite")] + [InlineData("Non-Player Killer")] + public void Bind_PkStatusLine_ShowsResolvedText_InAuthoredColor(string resolvedText) + { + var authoredColor = new Vector4(0.4f, 0.5f, 0.6f, 1f); + var pk = new UiText { DefaultColor = authoredColor }; + var layout = Fake((CharacterStatController.PkStatusId, pk)); + + CharacterStatController.Bind(layout, () => new CharacterSheet { PkStatus = resolvedText }); + + UiText.Line line = Assert.Single(pk.LinesProvider()); + Assert.Equal(resolvedText, line.Text); + Assert.Equal(authoredColor, line.Color); + } + + /// An unresolved PK key (CT4 contract: "no invented English; if + /// a key fails to resolve, show nothing") shows an empty line rather + /// than a fabricated English fallback. + [Fact] + public void Bind_PkStatusLine_NullPkStatus_ShowsEmptyText() + { + var pk = new UiText(); + var layout = Fake((CharacterStatController.PkStatusId, pk)); + + CharacterStatController.Bind(layout, () => new CharacterSheet { PkStatus = null }); + + Assert.Equal(string.Empty, pk.LinesProvider()[0].Text); + } + + /// CT4 item 5: the luminance pair (0x100005C5/0x100005C6) + /// toggles Visible per retail's exact gate — UpdateExperience + /// (0x004f0a70): "InqInt(0x19) < 200 || MaximumLuminance == 0" hides + /// both elements; otherwise both show. The elements' own content is left + /// unbound this slice (register row AP-109) — only visibility is + /// asserted here. + [Theory] + [InlineData(126, 0L, false)] // below level 200 — hidden regardless of luminance + [InlineData(200, 0L, false)] // level gate met, but MaximumLuminance == 0 — hidden + [InlineData(200, 1_000_000L, true)] // both conditions met — visible + [InlineData(275, 500L, true)] + [InlineData(null, 500L, false)] // absent level — treated as "not level 200+" + public void Bind_LuminancePair_TogglesVisibility_PerRetailGate(int? level, long maxLuminance, bool expectedVisible) + { + var label = new UiDatElement( + new ElementInfo { Id = CharacterStatController.LuminanceLabelId, Type = 3 }, + static _ => (0u, 0, 0)); + var value = new UiDatElement( + new ElementInfo { Id = CharacterStatController.LuminanceValueId, Type = 3 }, + static _ => (0u, 0, 0)); + var layout = Fake( + (CharacterStatController.LuminanceLabelId, label), + (CharacterStatController.LuminanceValueId, value)); + + CharacterStatController.Bind(layout, () => new CharacterSheet + { + Level = level, + MaximumLuminance = maxLuminance, + }); + + Assert.Equal(expectedVisible, label.Visible); + Assert.Equal(expectedVisible, value.Visible); + } + // ── XP meter fill ──────────────────────────────────────────────────────── [Fact] From e7e32409c21611d98f11c6faded14e613415a0f8 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 00:54:33 +0200 Subject: [PATCH 47/89] =?UTF-8?q?fix(ui):=20Campaign=20CT4=20fix=20round?= =?UTF-8?q?=20=E2=80=94=20luminance=20text,=20verbatim=20title,=20PK=20PWD?= =?UTF-8?q?=20bits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus dual-lens review of ed652ed8 found 2 blockers + 5 should-fix. All applied. BLOCKERS: - Bind the luminance pair (0x100005C5/0x100005C6): caption "Luminance:" (UTF-16, PE-byte-decoded from the gmStatManagementUI vftable-adjacent data at @0x007c3dd4) and value " / " (narrow "%s / %s" @0x007c3dcc) — both literals independently re-derived from the raw acclient.exe bytes and confirmed byte-exact against the review's claim. Numbers format through a new shared FormatXp helper (.ToString("N0", InvariantCulture) — retail's ExperienceSystem::XPToString equivalent), also now used by Total XP / XP-to-next-level (previously an un-invariant bare "N0"). Hide path switched from Visible=false to retail's own UIElement_Text::ClearAllText mechanism (@0x004f0e31/@0x004f0e3c — empty LinesProvider, leave layout); each LinesProvider re-reads data() on every draw, so no separate refresh call is needed. - CharacterIdentityText.StripLeadingArticle deleted: retail AppendText's the resolved title VERBATIM (@0x004f0990); 26 real ACE CharacterTitle entries begin with "The" and were being mangled. The dead CharacterSheet.Race fallback is deleted alongside it — retail's InqGenderHeritageDisplay creature-type argument is a hardcoded literal 0 (@0x004f08db), no producer exists. SHOULD-FIX: - PK line re-sourced: classifies off the live ClientObject.PublicWeenieBitfield PWD bits (0x20 IsPK / 0x02000000 IsPKLite — ACCWeenieObject::IsPK/IsPKLite @0x0058c8b0/@0x0058c8a0) instead of a bitwise test against raw PropertyInt 134, which carries ACE's own PlayerKillerStatus enum bit layout, not the PWD layout. PropertyInt 134 already drives the correct bits via the existing PlayerKillerStatusBitfield.Apply; this is a re-source, not new wiring. Deleted the 0x4|0x8 combined-flag test case, which asserted a non-retail answer. - Register AP-109 row: restores CT3's Titles-page narrowing paragraph (CT4's edit had compressed it to a bare pointer phrase), corrects the rank-prefix source to PropertyInt 0x1E (AllegianceRank) read live off the qualities bundle — not RuntimeAllegianceState, which is a different UI's (SocialAllegiancePageController) own documented substitute — corrects the title-table size from an estimated 22 functions/~200 strings to the actual 17 functions/~170 strings (AllegianceSystem::GetTitle's dispatch switch read directly), and downgrades the evidence claim. Filed AP-235 for the gender/heritage hardcoded-table-vs-live-EnumMapper mechanism divergence, pointing at the ALREADY-EXISTING RetailDataIdResolver.Resolve helper as CT5's unification seam. - CharacterPanelLiveDatTests.HeaderElements_AuthorExpectedFontsAndColors extended with the luminance pair's own occurrence-count + font/color pins, matching every other header id's pattern. Also landed: an InstalledDat pin (GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain) proving CharacterIdentityText.GenderDisplayName/HeritageGroupDisplayName match the live retail EnumMapper chain (master map category 1 -> ClientEnumToID[0x10000001]/[0x10000002] -> EnumMapper DIDs 0x2200000A/0x2200000B) byte-exact, including the two entries the review flagged as unverified guesses (10 "Penumbraen", 12 "Olthoi" — both correct). CharacterSheetProvider.BuildSheet's level read switched from a GetInt+ContainsKey double lookup to one TryGetValue. Plan ledger's test-provenance sentence corrected (Bind_HeaderElements_... predates CT4, extended to cover PkStatusId). Tests: CharacterStatControllerTests (verbatim title incl. "The Noob", luminance content/gate, luminance text binding, extended Bind_HeaderElements_... covering PkStatusId), CharacterSheetProviderTests (PK status driven through ClientObjectTable.UpdateIntProperty instead of a raw property write), CharacterPanelLiveDatTests (luminance pin, gender/ heritage EnumMapper pin). Full hermetic solution suite green under Release (0 failures, 15 projects); InstalledDat pins green (197/197, excluding one confirmed pre-existing unrelated failure — TowerAscentReplayTests, verified to fail identically with these changes stashed out). Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 5 +- ...6-08-24-character-panel-parity-campaign.md | 63 +++++++++- .../UI/Layout/CharacterIdentityText.cs | 102 ++++++++++------ src/AcDream.App/UI/Layout/CharacterSheet.cs | 10 +- .../UI/Layout/CharacterSheetProvider.cs | 77 +++++++++--- .../UI/Layout/CharacterStatController.cs | 111 ++++++++++++------ .../UI/Layout/CharacterPanelLiveDatTests.cs | 95 +++++++++++++++ .../UI/Layout/CharacterSheetProviderTests.cs | 23 +++- .../UI/Layout/CharacterStatControllerTests.cs | 103 +++++++++++++--- 9 files changed, 472 insertions(+), 117 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 28f3d4a4..a1cc6b97 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -212,7 +212,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 163 active rows (AP-234 filed 2026-08-23 at the #426 solid-face extraction fix — cell-wall geometry keeps approximating retail's "skip untextured subsets in a cell" with the polygon's NoPos flag rather than the Surface's own Type; AP-233 filed 2026-08-23 at the Holtburg windmill fix — the render-side inter-frame animation blend, now holding the boundary frame at every seam; AP-232 filed 2026-08-22 at Campaign VM VM1 — the #226 two-draw detail blend weight on TRANSLUCENT subsets versus retail's single stage-1 output alpha; AP-185 RETIRED 2026-08-20 — `RetailWindowLockPresentationController` now swaps all eight imported locked/live chrome blocks, hides live-only floating-chat and SmartBox grips, suppresses only the nine-slice grip overlay, and applies the current lock before a late-mounted window's first `OnShown`; the radar's persistent B7/B8 semantic face is pinned against pointer-state clobber and covered by a real-fixture draw cycle; AP-231 filed 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the Skills page formula-connector-text approximation in `ComposeFormula`, see the row's own text for the full disclosure of what is byte-verified versus best-derived; AP-213 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the remaining flat-list-vs-four-bucket-sorted-model half is now ported: `ChargenSkillDetail`/`ChargenSkillFormula` (Core) thread `SkillBase.MinLevel`/`Description`/`Formula` from the global SkillTable through `ChargenOptions.TryGetSkillDetail` (`ChargenTableReader.Project` populates it, live-DAT-pinned at 38 entries — 23 MinLevel<=1/15 MinLevel==2, matching the Batch F investigation's own recorded finding exactly), and `CharacterCreationSkillsPage` now groups every costable skill into `SkillBucket` (Specialized/Trained/UseableUntrained/UnuseableUntrained, `UpdateSkillEntry`'s own `iMinlevel <= 1` test), sorts each bucket alphabetically (`InsertEntrySorted`'s `wcscmp`, ported as `string.CompareOrdinal`), and builds one `Templates[0]` header row per bucket ahead of that bucket's `Templates[1]` skill rows — `DoSkillRecords`'s own unconditional 4-header-then-populate build order. A level change re-buckets the row (detected per-refresh against each row's own cached bucket, then a full rebuild — the observable placement matches retail's incremental single-row `InsertEntrySorted` move without reproducing its internal mechanism, a documented and harmless substitution). 3 new fixture tests (`SkillsPage_BucketHeaders_AlwaysBuildAllFour_InRetailOrder`, `SkillsPage_UntrainedSkill_BucketsByMinLevel`, `SkillsPage_AdvancingASkill_MovesItsRowIntoTheNewBucket`) plus 1 new live-DAT test (`InstalledSkillTable_GlobalSkillDetails_MinLevelDistributionMatchesCostCoverage`); AP-216/AP-217 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 1 — both rows' STOPPED items are now landed: `CharacterCreationUiController.AppearancePalSetSource`/`AppearanceClothingTableSource`/`AppearancePaletteColorSource` wire a DAT-backed `ChargenAppearanceCatalog` into the Appearance page from `LivePresentationComposition` (mirroring the existing `AppearancePreviewControl` seam), and `UiButton`/`UiDatElement` both gained a per-instance `Tint` property threaded into every existing `DrawSprite` call they make; `CharacterCreationAppearancePage` now sets `Tint` directly on each swatch button and the GradCircle element instead of layering a flat-fill `ChargenSwatchColorTile` overlay on top (that class is deleted) — a genuine multiplicative sprite tint on the widget's OWN authored art, matching retail's `SurfaceWindow::BlitAndColor(..., Blit_Multiply, color)` exactly rather than approximating it with an opaque rectangle. Both fixture test suites (`CharacterCreationAppearancePageSwatchColorTests`, 8 tests) and the live-DAT color pins (`ChargenAppearanceCatalogColorTests`) pass unchanged against the new mechanism; AP-218 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-6) — `gmCGAppearancePage::Update`'s heritage-flavored static Hair/Eyes/Skin spin caption (`ID_CharGen_HairStyle`/`_Eyes`/`_Skin`, Gearknight `GearText_*`, Olthoi/OlthoiAcid `OlthoiText_*`) is now ported verbatim by `RefreshSpinCaptions`, replacing the prior ordinal substitution outright — see AP-215's own rewritten row for what remains open (the icon-thumbnail gap, restated); recount at this same edit: the row count this header carried before Batch B was already one LOW relative to the physical table (Batch A's own ending state: header said 164, the physical table already held 165 rows — verified by direct count against that commit) — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change (F12 correction, gate round 1 closeout, 2026-08-16: this note originally said "one high", the inverted direction — the header was UNDER-counting, not over-counting); AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 164 active rows (AP-235 filed 2026-08-25 at the Campaign CT4 fix round — `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# switches instead of a live `EnumMapper` read; AP-234 filed 2026-08-23 at the #426 solid-face extraction fix — cell-wall geometry keeps approximating retail's "skip untextured subsets in a cell" with the polygon's NoPos flag rather than the Surface's own Type; AP-233 filed 2026-08-23 at the Holtburg windmill fix — the render-side inter-frame animation blend, now holding the boundary frame at every seam; AP-232 filed 2026-08-22 at Campaign VM VM1 — the #226 two-draw detail blend weight on TRANSLUCENT subsets versus retail's single stage-1 output alpha; AP-185 RETIRED 2026-08-20 — `RetailWindowLockPresentationController` now swaps all eight imported locked/live chrome blocks, hides live-only floating-chat and SmartBox grips, suppresses only the nine-slice grip overlay, and applies the current lock before a late-mounted window's first `OnShown`; the radar's persistent B7/B8 semantic face is pinned against pointer-state clobber and covered by a real-fixture draw cycle; AP-231 filed 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the Skills page formula-connector-text approximation in `ComposeFormula`, see the row's own text for the full disclosure of what is byte-verified versus best-derived; AP-213 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the remaining flat-list-vs-four-bucket-sorted-model half is now ported: `ChargenSkillDetail`/`ChargenSkillFormula` (Core) thread `SkillBase.MinLevel`/`Description`/`Formula` from the global SkillTable through `ChargenOptions.TryGetSkillDetail` (`ChargenTableReader.Project` populates it, live-DAT-pinned at 38 entries — 23 MinLevel<=1/15 MinLevel==2, matching the Batch F investigation's own recorded finding exactly), and `CharacterCreationSkillsPage` now groups every costable skill into `SkillBucket` (Specialized/Trained/UseableUntrained/UnuseableUntrained, `UpdateSkillEntry`'s own `iMinlevel <= 1` test), sorts each bucket alphabetically (`InsertEntrySorted`'s `wcscmp`, ported as `string.CompareOrdinal`), and builds one `Templates[0]` header row per bucket ahead of that bucket's `Templates[1]` skill rows — `DoSkillRecords`'s own unconditional 4-header-then-populate build order. A level change re-buckets the row (detected per-refresh against each row's own cached bucket, then a full rebuild — the observable placement matches retail's incremental single-row `InsertEntrySorted` move without reproducing its internal mechanism, a documented and harmless substitution). 3 new fixture tests (`SkillsPage_BucketHeaders_AlwaysBuildAllFour_InRetailOrder`, `SkillsPage_UntrainedSkill_BucketsByMinLevel`, `SkillsPage_AdvancingASkill_MovesItsRowIntoTheNewBucket`) plus 1 new live-DAT test (`InstalledSkillTable_GlobalSkillDetails_MinLevelDistributionMatchesCostCoverage`); AP-216/AP-217 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 1 — both rows' STOPPED items are now landed: `CharacterCreationUiController.AppearancePalSetSource`/`AppearanceClothingTableSource`/`AppearancePaletteColorSource` wire a DAT-backed `ChargenAppearanceCatalog` into the Appearance page from `LivePresentationComposition` (mirroring the existing `AppearancePreviewControl` seam), and `UiButton`/`UiDatElement` both gained a per-instance `Tint` property threaded into every existing `DrawSprite` call they make; `CharacterCreationAppearancePage` now sets `Tint` directly on each swatch button and the GradCircle element instead of layering a flat-fill `ChargenSwatchColorTile` overlay on top (that class is deleted) — a genuine multiplicative sprite tint on the widget's OWN authored art, matching retail's `SurfaceWindow::BlitAndColor(..., Blit_Multiply, color)` exactly rather than approximating it with an opaque rectangle. Both fixture test suites (`CharacterCreationAppearancePageSwatchColorTests`, 8 tests) and the live-DAT color pins (`ChargenAppearanceCatalogColorTests`) pass unchanged against the new mechanism; AP-218 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-6) — `gmCGAppearancePage::Update`'s heritage-flavored static Hair/Eyes/Skin spin caption (`ID_CharGen_HairStyle`/`_Eyes`/`_Skin`, Gearknight `GearText_*`, Olthoi/OlthoiAcid `OlthoiText_*`) is now ported verbatim by `RefreshSpinCaptions`, replacing the prior ordinal substitution outright — see AP-215's own rewritten row for what remains open (the icon-thumbnail gap, restated); recount at this same edit: the row count this header carried before Batch B was already one LOW relative to the physical table (Batch A's own ending state: header said 164, the physical table already held 165 rows — verified by direct count against that commit) — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change (F12 correction, gate round 1 closeout, 2026-08-16: this note originally said "one high", the inverted direction — the header was UNDER-counting, not over-counting); AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -220,6 +220,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| AP-235 | **Filed 2026-08-25 at the Campaign CT4 fix round.** Retail resolves gender display text via `AppraisalSystem::InqGenderDisplayName @0x005b47c0` and heritage via `InqHeritageGroupDisplayName @0x005b4710`, both through the static `EnumMapper::GetString(uint32_t enumValue, uint32_t queryId, PStringBase*) @0x0041ac40` overload — `DBObj::GetDIDByEnum(&did, enumValue, 1)` (master map `0x25000000` → category-1 sub-map `0x25000001` → `ClientEnumToID[0x10000001]`/`[0x10000002]` → EnumMapper DIDs `0x2200000A`/`0x2200000B`) — reading each id's `IdToStringMap` entry live, with heritage ids 2/5/0xd hardcoded to `"Gharu'ndim"`/`"Umbraen"`/`"Olthoi"` in place of the raw internal names `"Gharundim"`/`"Shadowbound"`/`"OlthoiAcid"`. `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# `switch` tables instead — a mechanism divergence (compile-time constant vs. live DAT read), not a content one: `CharacterPanelLiveDatTests.GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain` (filed the same round) walks the live EnumMapper chain and asserts every table entry byte-exact, including the two entries (10 "Penumbraen", 12 "Olthoi") the CT4 review had flagged as unverified guesses — both are correct. | `src/AcDream.App/UI/Layout/CharacterIdentityText.cs` (`GenderDisplayName`, `HeritageGroupDisplayName`); `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs` (`ResolveHeritage` — a SECOND, independent re-implementation of the same 2/5/13 overrides, its own instance of this row's divergence) | `RetailDataIdResolver.Resolve` (`src/AcDream.Content/RetailDataIdResolver.cs`) already ports the generic two-level `GetDIDByEnum` chain (used today for layout/material DIDs); unifying gender/heritage onto it needs only `Resolve(dats, enumValue: 0x10000001u/0x10000002u, enumCategory: 1u)` plus an `EnumMapper.IdToStringMap` read — a live-DAT-only path with no bespoke traversal code to write, which is why the tables stayed hardcoded this round rather than porting live-read on the spot; CT5 is the natural landing slot since it already owns this same DAT-lookup family for the Titles page | A future DAT/game update that renames or reorders a heritage/gender enum entry would silently desync acdream's hardcoded tables from retail's live text with no build-time or runtime signal, and `RetailAppraisalNameResolver.ResolveHeritage`'s independent copy could drift from `CharacterIdentityText`'s even absent any DAT change | `AppraisalSystem::InqGenderDisplayName @ 0x005B47C0`; `InqHeritageGroupDisplayName @ 0x005B4710`; `EnumMapper::GetString @ 0x0041AC40`; `DBObj::GetDIDByEnum @ 0x004153A0` | | AP-234 | **Filed 2026-08-23 at the #426 solid-face extraction fix.** Cell-wall (EnvCell/CellStruct) geometry approximates retail's "skip untextured subsets inside a cell interior" with the polygon's own `Stippling.NoPos` flag rather than resolving the Surface's own `Type` (`Base1Image`/`Base1ClipMap`) before the per-polygon draw decision — the same NoPos-vs-surface-type conflation #426 fixed for ordinary GfxObj extraction (`PrepareGfxObjMeshData`/`GfxObjMesh.Build`), deliberately LEFT in place here | `src/AcDream.Core/Meshing/CellMesh.cs:45`; `src/AcDream.Content/MeshExtractor.cs`'s `PrepareCellStructMeshData` `hasPos` gate carries the identical rule | Cells are the one retail context that genuinely skips untextured subsets (`DrawEnvCell`), so approximating "untextured" with NoPos is directionally correct for the common case — a solid-colour polygon always carries NoPos since it has no UVs to carry; resolving Surface.Type first would need a per-polygon dat lookup this code doesn't currently perform before the emit/skip decision | A textured polygon whose author left NoPos set (no positive UVs authored despite a real texture) would be wrongly skipped as if untextured, or an untextured polygon whose author left NoPos unset would wrongly draw — either edge case shows as a cell wall gaining or losing a face relative to retail | `RenderDeviceD3D::DrawEnvCell` @0x0059f170 → `D3DPolyRender::DrawMesh(..., arg4=1)`; `RetailUntexturedSurfacePolicy`/`RetailUntexturedSubsetPolicy` (`src/AcDream.Core/Meshing/RetailUntexturedSurfacePolicy.cs`) | | AP-233 | **Filed 2026-08-23 at the Holtburg windmill fix (row owed since the R1-P5 sequencer cutover).** `AnimationSequencer.BuildBlendedFrame` blends each part between `floor(FrameNumber)` and the next frame in the playback direction using the retail slerp (`SlerpRetailClient`). Retail never blends animation frames: `CPartArray::UpdateParts` applies `CSequence::get_curr_animframe` = `get_part_frame(floor(frame_number))`, holding every authored 30 fps frame for its whole interval. Since 2026-08-23 the blend holds the boundary frame at BOTH ends of a node's window — including the cyclic seam — so a cycle's last→first transition is retail's hard cut, not a blend. | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`BuildBlendedFrame`); tests `AnimationSequencerTests.Advance_LinkTailDoesNotBlendIntoLinkFrame0` (#61), `Advance_CyclicSeamHoldsLastFrameInsteadOfBlendingIntoFrame0` (windmill) | The blend only smooths between authored interior frames of one node; at every seam the pose is exactly retail's held frame. Authored cycles that loop by symmetry (the Holtburg windmill's 60-frame quarter turn, `0x0300061B`) or by design read identically at the seam; link tails hold their end pose (#61). The owner chose this over dropping the blend (retail's 30 fps stepping) on 2026-08-23. | Any two adjacent authored frames that are NOT meant to be traversed smoothly (a deliberate authored pop inside a node) would be smoothed where retail pops; none known. A per-frame hitch of one held 33 ms interval at each cycle seam is the price of the cut (1.5° on the windmill). | `CPartArray::UpdateParts @0x005190F0`; `CSequence::get_curr_animframe @0x00524970`; `CSequence::get_curr_frame_number @0x005249D0` | | AP-232 | **Filed 2026-08-22 at Campaign VM slice VM1 (the #226 single-pass re-port; deviation introduced at `05970306`, row owed since then).** Retail's single-pass detail combine produces ONE pixel per subset whose OUTPUT alpha is stage 1's `MODULATE(TEXTURE, CURRENT)` (`D3DPolyRender::SetSurface @0x0059c4d0`, op at `0x0059c549`) — for a delayed-alpha (translucent) subset that product is the framebuffer blend weight. acdream draws the base subset with its own alpha, then a second `mesh_detail` draw weighted by `detail.a * instanceOpacity` under `SRCALPHA + INVSRCALPHA`. For OPAQUE subsets (base alpha 1) the two compose to exactly `lerp(base, detail, detail.a*opacity)` and, with both draws fogged, to retail's fog-after-combine pixel (identity pinned by `RetailDetailTextureContractTests`). For TRANSLUCENT building/EnvCell subsets the destination after the base draw is `mix(behind, foggedBase, baseAlpha)`, not `foggedBase`, so the detail weight differs from retail's single product. | `src/AcDream.App/Rendering/Shaders/mesh_detail.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs` (transparent interleave); `src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs` (transparent interleave) | Opaque subsets are the overwhelming majority of building shells and interior walls and are exact; translucent detail-bearing subsets (ClipMap/alpha/additive/inverse-alpha glass and grates) get a bounded weight difference that never exceeds the detail texture's own alpha (mean 0.132 on the live Dereth category texture). Collapsing to one draw would require the base pipelines to sample the detail texture, i.e. a second `mesh_modern` variant on the retail path. | A translucent building/EnvCell surface with the detail preference on reads visibly different from retail against a bright background. Separate from AP-34 (queue ORDER); this row is about the blend WEIGHT. | `D3DPolyRender::SetSurface @0x0059c4d0` (stage table), `RenderMeshSubset @0x0059ca10`; VM2 cdb note `docs/research/2026-08-22-vm2-retail-detail-path-cdb.md` | @@ -356,7 +357,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-105 | **PARTIAL 2026-07-13** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`), and both chat backends share typed client-command routing plus one retained `ChatVM` for reply state. Retained chat still lacks complete tab/filter/unread, social availability, incoming squelch enforcement, and focus-opacity behavior. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, command ownership, and outer maximize geometry work; later chat work consolidates the remaining presentation/filter state | Tabs are no-ops, squelched lines can still render, contextual social actions are absent, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods | | ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | | AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` | -| AP-109 | **NARROWED FURTHER 2026-08-24 at Campaign CT slice CT4 — the header identity block is now LIVE.** (CT3's Titles-page narrowing above still stands verbatim.) `CharacterStatController`'s Name/Heritage/PkStatus/Level labels now use `LabelAuthoredColor` (the widget's own DAT-set `DefaultColor`/Outline, matching CT1's live-DAT pin — the former hardcoded `Body`/`Gold` runtime constants are deleted); the heritage line appends CT2/CT3's resolved display title (`CharacterSheetProvider` now takes `RuntimeCharacterTitleState`/`CharacterTitleResolver.Resolve`, refreshing on both `TableReplaced` and `DisplayTitleChanged`); the PK line resolves through StringTable `0x23000001` by key (`ID_StatManagement_Header_PKStatus_PK`/`_PKL`/`_NPK`, DAT-verified strings "Player Killer"/"Player Killer Lite"/"Non-Player Killer") with a bitwise IsPK/IsPKLite test (retail `UpdatePKStatus @0x004F00A0`) instead of the prior exact-equality switch; the level shows `"%d"`-formatted `InqInt(0x19)` or the literal `"???"` when absent (both PE-recovered). Two items remain open, both registered rather than silently dropped: (1) the NAME line ships the PLAIN-NAME case only — retail's allegiance-rank prefix (`AllegianceData::GetFullName @0x005b6950` → `AllegianceSystem::GetTitle @0x005b8dd0`) needs a ~200-string, 22-function heritage×gender title table (verbatim in the decomp, e.g. `GetAluvianMaleTitle @0x005b7bc0`'s "Yeoman"/"Baronet"/.../"High King") that CT4 judged out of "reasonable size" for this slice — `RuntimeAllegianceState` already carries the local player's own rank (`_rank`/`ApplyUpdate`, seeded by `0x0020 AllegianceUpdate`), so only the string table is missing; (2) the luminance pair (`0x100005C5`/`0x100005C6`) has its DATA (`CharacterSheet.AvailableLuminance`/`MaximumLuminance`, retail PropertyInt64 6/7 — already flowing generically through both the PlayerDescription snapshot and the live `0x02CF` private-update parsers, no wiring gap) and its retail show/hide GATE (`Level >= 200 && MaximumLuminance != 0`, `UpdateExperience @0x004F0A70`) wired and toggling `Visible`, but no TEXT is bound — the label's caption and the value's composed "available/maximum" string both resolve in retail through a `SetText` call whose source string Binary Ninja mislabels as a vftable slot rather than a StringTable key, and a DAT string-table sweep this slice found no matching entry, so content stays blank pending a PE-byte-decode pass. | `src/AcDream.App/UI/Layout/CharacterStatController.cs` (`LabelAuthoredColor`, `RefreshLuminanceVisibility`); `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs`; `src/AcDream.App/UI/Layout/CharacterSheet.cs`; `src/AcDream.App/UI/Layout/CharacterIdentityText.cs`; `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs`; `src/AcDream.App/UI/Layout/CharacterTitlesController.cs` (Titles page, CT3, unchanged) | Attributes/skills core output and the Titles-page binding seam are user-accepted; CT4's header-identity binding-seam tests assert the composed heritage line, the three PK keys, the level integer/"???" fallback with authored (not constant) color, and the luminance show/hide rule against a real fixture, plus an InstalledDat pin for the three PK strings | A ranked-allegiance character's Name line shows plain name only (no title prefix) until the 22-function table is ported; a level-200+ character with luminance sees the pair correctly appear/disappear but with no caption or numbers until the exact retail string is recovered | `gmStatManagementUI::UpdateCharacterInfo @ 0x004F0770`; `UpdatePKStatus @ 0x004F00A0`; `UpdateExperience @ 0x004F0A70`; `AllegianceData::GetFullName @ 0x005B6950`; `AllegianceSystem::GetTitle @ 0x005B8DD0` | +| AP-109 | **NARROWED FURTHER 2026-08-25 at the Campaign CT4 fix round — the luminance pair's TEXT is now bound and the PK classification now reads the live PWD bits, closing both out of this row.** `CharacterSheetProvider.PkStatusText` classifies off `ClientObject.PublicWeenieBitfield` bits `0x20` (IsPK) / `0x02000000` (IsPKLite) — the exact `ACCWeenieObject::IsPK @0x0058c8b0` / `IsPKLite @0x0058c8a0` PWD-bitfield reads, ported already at `PlayerKillerStatusBitfield.Apply` (#297) — instead of the CT4-landed bitwise test against raw PropertyInt 134 (a non-retail mapping: PropertyInt 134 carries ACE's own `PlayerKillerStatus` enum values, not the PWD bit layout). `CharacterStatController`'s luminance pair (`0x100005C5`/`0x100005C6`) now binds real text: caption `"Luminance:"` (UTF-16, PE-byte-recovered from the `gmStatManagementUI` vftable-adjacent data region at `@0x007c3dd4`) and value `" / "` (narrow `"%s / %s"` format, PE-byte-recovered at `@0x007c3dcc`, args in that order per `UpdateExperience`'s call sequence — `ExperienceSystem::XPToString(AvailableLuminance, ...)` then `XPToString(MaximumLuminance, ...)`), both numbers formatted through the same shared `FormatXp` helper the Total XP / XP-to-level fields use (`.ToString("N0", CultureInfo.InvariantCulture)` — the C# equivalent of retail's `XPToString`→`GetNumberFormatA` locale-grouped-decimal call; not a byte-identical Win32 port, so an exotic edge case, e.g. negative/overflow, is this row's own residual sliver if one is ever found). The hide path is retail's own `UIElement_Text::ClearAllText` (`@0x004f0e31`/`@0x004f0e3c` — empties `LinesProvider` content, leaves layout) rather than `Visible = false`. (CT3's Titles-page narrowing, restored here verbatim after CT4's edit compressed it to a bare pointer phrase, still stands:) **CT3's narrowing (2026-08-24), verbatim:** `CharacterTitlesController` binds the Titles page (`gmCharacterTitleUI`, LayoutDesc `0x2100002E` element `0x10000539`) through the standard `UiTemplateListBox`/`UiScrollbar`/`UiButton` classes — no bespoke widgets: the earned-titles list sorted by resolved display string (`FindSortedInsertPosition @0x0049A760`), the current display-title text (`Refresh @0x0049abc0`, including its hardcoded `"Unknown"` fallback, refreshed on both the table-replace and display-change notices), row selection using the row template's own authored Highlight state (the same `InfoRegion::SetState(6)` mechanism CT1 confirmed for the stat rows), the "Set as Display Title" button's Ghosted-unless-a-differing-selection gate (`UpdateButtons @0x0049A500`, CORRECTED direction per the CT campaign plan's CT1 fix round — no selection is the Ghosted case), and the `TitleSet (0x002C)` wire send through CT2's `RuntimeCharacterTitleState`/`IRuntimeCharacterCommands.SetTitle` (no local mutation). Campaign CT slice CT4 (2026-08-24) then put the header identity block live: `CharacterStatController`'s Name/Heritage/PkStatus/Level labels use `LabelAuthoredColor` (the widget's own DAT-set `DefaultColor`/Outline, matching CT1's live-DAT pin — the former hardcoded `Body`/`Gold` runtime constants are deleted); the heritage line appends CT2/CT3's resolved display title VERBATIM (`CharacterIdentityText.StatHeaderLine`, CT4-fix-round-corrected 2026-08-25 to stop stripping a leading "The " — retail `AppendText`s the resolved title unmodified at `@0x004f0990`, and 26 real ACE `CharacterTitle` entries begin with "The"); the level shows `"%d"`-formatted `InqInt(0x19)` or the literal `"???"` when absent (both PE-recovered). One item remains open, registered rather than silently dropped: the NAME line ships the PLAIN-NAME case only — retail's allegiance-rank prefix (`AllegianceData::GetFullName @0x005b6950` → `AllegianceSystem::GetTitle @0x005b8dd0`) needs a ~170-string, **17-function** [CORRECTED 2026-08-25 from CT4's original 22-function/~200-string estimate — `GetTitle`'s own dispatch switch (`@0x005b8dd0`) was read directly: Gearknight and Tumerok author only a MALE `Get*Title` function, reused for both genders' dispatch branches, and Lugian authors only a FEMALE one, reused for both — 11 heritages produce 17 functions, not 22 (2 each for Aluvian/Gharu'ndim/Sho/Viamontian/Shadowbound/Empyrean/Undead, 1 each for Gearknight/Tumerok/Lugian); Olthoi/OlthoiAcid (heritage ids 12/13) have no title function at all — `GetTitle`'s own range check `(heritage-1) <= 0xa` (unsigned) excludes them, and heritage id `0xa` (Penumbraen) aliases to the Shadowbound functions] heritage×gender title table (verbatim in the decomp, e.g. `GetAluvianMaleTitle @0x005b7bc0`'s "Yeoman"/"Baronet"/.../"High King") that CT4 judged out of "reasonable size" for this slice. The RANK value is PropertyInt `0x1E` (`AllegianceRank`) read LIVE off the qualities bundle (`CBaseQualities::InqInt(qualities, 0x1e)` — ACE actively pushes this property on every allegiance-rank change) [CORRECTED 2026-08-25 — CT4's original text claimed `RuntimeAllegianceState` "already carries the local player's own rank," conflating this row's context with `SocialAllegiancePageController`'s OWN, DIFFERENT, already-documented substitution (that controller has no qualities-bundle access, so it renders `RuntimeAllegianceSnapshot.Rank` — same `0x0020 AllegianceUpdate` wire message, numerically equivalent in every observed case — as its own accepted stand-in). `CharacterSheetProvider.BuildSheet` already reads every other header property straight off `props.GetInt(...)` from the qualities-equivalent `PropertyBundle`, so the correct future port reads `props.GetInt(0x1Eu)` directly, not `RuntimeAllegianceState` — only the STRING table is missing, not the data]. | `src/AcDream.App/UI/Layout/CharacterStatController.cs` (`LabelAuthoredColor`, `RefreshLuminanceVisibility`, `FormatXp`); `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`PkStatusText`); `src/AcDream.Core/Items/ClientObject.cs` (`PlayerKillerStatusBitfield`); `src/AcDream.App/UI/Layout/CharacterSheet.cs`; `src/AcDream.App/UI/Layout/CharacterIdentityText.cs`; `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs`; `src/AcDream.App/UI/Layout/CharacterTitlesController.cs` (Titles page, CT3, unchanged) | Attributes/skills core output and the Titles-page binding seam are user-accepted; evidence for the header-identity block is synthetic-layout binding tests plus a small number of InstalledDat string/DID pins (the three PK strings, the gender/heritage EnumMapper chain at AP-235) — not a connected/live gate | A ranked-allegiance character's Name line shows plain name only (no title prefix) until the 17-function table is ported | `gmStatManagementUI::UpdateCharacterInfo @ 0x004F0770`; `UpdatePKStatus @ 0x004F00A0`; `UpdateExperience @ 0x004F0A70`; `UIElement_Text::ClearAllText @ 0x004F0E31`/`0x004F0E3C`; `ACCWeenieObject::IsPK @ 0x0058C8B0`; `IsPKLite @ 0x0058C8A0`; `AllegianceData::GetFullName @ 0x005B6950`; `AllegianceSystem::GetTitle @ 0x005B8DD0` | | AP-110 | **NARROWED 2026-08-09 (Slice 5.4, vendor browse panel) — "vendor" retired from the absent-panels list; see AP-161 for the precise successor (Buy/Sell transaction UI, Slice 6).** Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, inscription transaction, retail creature stat/rating/animated-preview presentation, default selection-follow, authored local spell subview with appropriate-formula component state, and the full EoR item-report dispatch: appraisal-only unknowns; exact equipment-set/rating/tinkering/weapon/armor/caster/requirement/XP/healer/rare prose and intentional blank section rows; ordinary/enchantment DAT spell descriptions; live material-decorated appropriate titles plus DAT material and creature names; expiry, decorated material/gem descriptions; and portal/PK restrictions with authored item colors. It still lacks item-object preview, player-dependent effective shield projection, live cooldown-remaining projection, localized augmentation-cost `StringInfo`, exhaustive character detail regions, and exact creature appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, the Slice 5.4 vendor "Items" browse panel (category-filtered stock list, retail's quantity-correct pricing — `ItemHolder::GetObjectSplitSize`'s split-exempt mask, not a flat per-unit price), and the core examination request/presentation/inscription/creature-preview/item-report loop cover the active loops; the residual examination mechanisms require live player/enchantment/localization state or object-preview ownership rather than fabricated content | Item assessments omit only the listed live/localized/preview projections; enchanted/incomplete creature appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `gmExaminationUI::ExamineSpell @ 0x004B6900`; `SpellExamineUI::ExamineSpell @ 0x004B6210`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ACCWeenieObject::GetObjectName @ 0x0058E6E0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::AddItemInfo @ 0x004AC050`; `ItemExamineUI::Appraisal_ShowCapacity @ 0x004B2680`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::Appraisal_ShowDescription @ 0x004B6990`; `MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog | | AP-161 | **REVIEW CORRECTIONS 2026-08-09 (Opus review of `92ea3977`, findings F1-F13):** thirteen further fixes, mostly bug-fixes-to-already-claimed-behavior rather than new divergences, so no new AP row is filed for most of them; the exceptions are called out below. F1 ports Buy All's four retail pre-send guards (pyreal/alt-currency affordability, container-slot/item-slot capacity) — see AP-162's narrowing. F2 corrects `AddToBuyList` from upsert to retail's actual ACCUMULATE-with-5000-cap semantics and ports `RemoveFromShop`'s shop-row hide/restore as staging consumes limited vendor supply. F3 corrects `VendorSellAcceptability`'s too-valuable branch to the byte-verified bitwise-complement form (`(~(itemTypeMask >> 16)) & 4`), exempting `PromissoryNote` items. F4 wires `BF_RETAINED` end to end, RETIRING AP-164 below. F5 ports `UpdateDragOver`'s auto-switch-to-Selling-on-hover. F6 corrects sell staging to always record the FULL stack (never the live split slider) and ports `SellSingleItem`'s partial-stack refusal plus its literal amount-1 send. F7 corrects the X-close confirmation string's missing trailing question mark. F8 disposes a live confirmation dialog on session Close/Reset. F9 repaints the Buying/Selling strips' own selection highlight on every selection change, not just a staging change. F10 unstages a sell entry that leaves `ClientObjectTable` and a buy entry whose shop row is retired, the latter with retail's exact notice. F11 reorders `RequestUse`'s eligibility check ahead of `BeginApproach` so an ineligible far target no longer speculatively approaches. F13 makes Sell Item act on the global selection unconditionally, matching retail — a prior version of this port required a staged entry first. New approximations this pass introduced are filed as AP-167 (`SellSingleItem`'s non-empty-container refusal branch not ported) and AP-168 (Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container`). **NARROWED 2026-08-09 (Slice 6b/6c, staging+sell arc) — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES.** `VendorUiController` now fully wires both tabs: Buying (`Add to List`/`Buy Item`/`Buy All`/`Clear Item`/`Clear List`, backed by `VendorStagingList`) and Selling (drag-to-sell via `IItemListDragHandler`, `VendorSellAcceptability`'s port of `VendorProfile::InqAcceptability`, `Sell Item`/`Sell All`/`Clear Item`/`Clear List`), plus the X-close staging confirmation dialog (`RetailDialogFactory`, the exact retail string recovered from the decompiled binary's data segment at `0x007b5bd8`). Sell (`0x0060`) is wired end to end (`VendorRequests.BuildSell`/`WorldSession.SendSell`/`ItemInteractionController.TrySell`). Three narrow residuals from this pass are filed separately rather than folded in here: `InqAcceptability`'s non-sellable bitfield check is unmodeled (AP-164), the Buying tab's stackable-removal-amount test substitutes `VendorShopItem.DescStackSize` for retail's `_maxStackSize` (AP-165), and the Buying/Selling tabs' own per-row/purse count text plus the cross-panel "pending sell" inventory highlight are not wired (AP-166). The two PRE-EXISTING residuals below (dropdown arrow-cap glyph, alt-currency `m_last_sale` simplification) are UNCHANGED by this pass — see the ORIGINAL text below for their citations. **REVIEW CORRECTIONS 2026-08-09 (Opus review of `97cf8738`, findings F1-F9):** none of these are NEW divergences from retail — they are bug fixes that make this row's own claims actually true, so no new AP row is filed for them. F2 fixed the priced/named quantity freezing at a selection-time seed while the Buy button separately read the LIVE slider — both now share one `ResolveBuyQuantity` computation, so the displayed price always equals what a purchase actually charges (retail: `gmVendorUI::RecvNotice_StackSliderChanged` re-runs the SAME display update on every slider change, `pc:203262-203278`). F6 corrected an unauthored "preserve the prior selection if it survives the filter" rule to retail's actual UNCONDITIONAL reselect-to-first-item on every rebuild this controller reaches (`VendorItemsUI::UpdateItemsList`'s notify=1 path, `pc:201180-201184`, confirmed reached by a fresh open AND a same-vendor refresh via `VendorItemsUI::OpenVendor`'s unconditional `SetSelectedItem(...,1)`, `pc:201022`). F7 ported `BuySingleItem`'s stack-size-1 quantity clamp (`pc:201674-201681`) so a stale slider value left over from a previously-selected, DIFFERENT stackable item cannot leak into a non-stack purchase. F8 is recorded inline below, where it corrects this row's own stale claim about the Add-to-List button. **NARROWED 2026-08-09 (Slice 6.1-6.3, buy arc) — TWO of the four consciously-deferred residuals below CLOSE.** Private per-panel selection is GONE: `SelectionState` gains a `Vendor` change source (`SelectionChangeSource.Vendor`) and is now the AUTHORITY — row clicks, the F4 auto-select-first-item fallback, and right-click examine all call `SelectionState.Select`/`Clear`; `VendorUiController` is a CONSUMER (`OnSelectionTransition`) exactly like every sibling panel, matching retail's global `ACCWeenieObject::selectedID`. The examine gap (F7c) is GONE too: `VendorShopItemMaterializer` (`src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs`, Slice 6.1) registers every `ApproachVendor` shop item into `ClientObjectTable` (guid, `ContainerId = vendorGuid`, merge-upserted via the ordinary `Ingest` path, retired on session Close/Reset/vendor-replace via the SAME `VendorState.Changed` subscription) so `AppraisalUiController.Apply`'s lookup now succeeds; `VendorUiController.ExamineItem` wires `UiItemList.ExamineItemRequested` to `ItemInteractionController.ExamineSelectedOrEnterMode`, mirroring `ExternalContainerController`. **Double-click-to-buy was investigated (research doc `docs/research/2026-08-08-slice6-vendor-transactions-research.md` §B.2) and confirmed ABSENT from retail** — no `gmVendorUI::CheckForDoubleClick`/`VendorItemsUI::CheckForDoubleClick` symbol exists anywhere in the 18,366-function named table, unlike sibling panels (`gmContractsUI::CheckForDoubleClick`, `gmPageListUI::CheckForDoubleClick`) that DO have one; acdream intentionally does NOT add a double-click shortcut — a user request for it as a deliberate acdream-only UX addition would need its own AP row, per CLAUDE.md's no-invented-mechanisms discipline. The remaining two residuals (dropdown arrow-cap glyph, alt-currency `m_last_sale` simplification) are UNCHANGED — see below. New approximations this pass introduced are filed separately as AP-162 (no client-side Buy pre-check) and AP-163 (shop-item guid-collision policy). **Original REWRITTEN text follows, retained for the two still-open residuals:** `VendorUiController` mounts LayoutDesc `0x21000012`/root `0x100000B7` and fully wires only the authored "Items" tab (`0x100000B9` — `VendorItemsUI`: category-filtered browse list with retail's quantity-correct pricing, `ItemHolder::GetObjectSplitSize`'s `0xDC41CB0` split-exempt mask ported locally rather than a flat per-unit price). The other two authored tabs render and switch pages (so the layout looks complete) but are otherwise INERT: "Buying" (`0x100000BA`, `VendorBuyUI` — staged-purchase review/confirm, buttons `0x100000C9`/`CA`/`CB`/`CC`) and "Selling" (`0x100000BB`, `VendorSellUI` — staged-sale review/confirm, buttons `0x100000D2`/`D3`/`D4`/`D5`) have no data binding at all. The "Items" page's own `Buy` button (`0x100000C2`) correctly enables/disables with selection (`UiButton.Enabled`, retail `SetState(1)`/`SetState(0xd)`) and Slice 6.3 wires it to a real immediate single-item purchase (`gmVendorUI::BuySingleItem`, `pc:201661` — `VendorRequests.BuildBuy`/`WorldSession.SendBuy`, opcode `0x005F`). **Review correction 2026-08-09 (F8):** `Add to List` (`0x100000C3`, staging) does NOT enable/disable with selection — it is PERMANENTLY disabled (`VendorUiController.SetActionButtonsEnabled`), because it has no wired `OnClick` at all; an enabled-but-dead button is a worse affordance than a disabled one, so it stays disabled until the "Buying" tab's staging list is actually implemented. The Buy opcode exists on the wire now; Sell (`0x0060`) does not. `VendorProfile::InqAcceptability` (sell-eligibility filtering) is unread — moot without a sell UI. Two divergences remain of the four the F1-F8 fix pass originally recorded — the other two (private per-panel selection, unwired shop-item examine) CLOSED at Slice 6.1/6.2, see the NARROWED note above: (1) the closed-dropdown button face reuses the row template's own two sprites (`0x060012B3` normal/`0x060012B4` open) through `UiMenu`'s existing single-texture 3-slice `DrawButtonFace` instead of retail's authored two-piece label+arrow-cap assembly (label `0x1000034D` + a separate 17x19 arrow cap `0x1000034E` with its own `0x060012B1`/`0x060012B2` states) — a cosmetic gap only; the popup panel and its rows render with the exact authored geometry and sprites; (2) the alt-currency "you have" holding reads `VendorShopProfile.AlternateCurrencyAmount` directly instead of tracking retail's `gmVendorUI.m_last_sale` purchase debit — moot until a sell path exists to actually debit it, since `m_last_sale` only changes on a completed SALE (retail's own `m_last_sale == 0` case, `pc:204091`/`OpenVendor`'s `this->m_last_sale = 0` reset at `pc:203790`/`203800`); Slice 6.3's buy path does not touch `m_last_sale` either (retail's own buy flow never writes it), so this residual is unaffected by the buy arc landing. The "Buying"/"Selling" staging tabs (`VendorBuyUI`/`VendorSellUI`) and the full Sell wire remain unwired — unchanged Slice 6b/6c territory per contract decision 6, not a residual of THIS row. | `src/AcDream.App/UI/Layout/VendorUiController.cs`; `src/AcDream.Core/Items/VendorState.cs`; `src/AcDream.Core.Net/GameEventWiring.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs` | Slice 6 (`docs/plans/2026-07-23-world-interaction-completion.md`) owns the authoritative buy/sell transaction command, quantity/stack-split selection, drag-to-sell consumption, and `InqAcceptability`-gated sell UI — Slice 5.4's charter was browse only. Buy (6.3), the global `ACCWeenieObject::selectedID` coupling (6.2), and shop-item `ClientObjectTable` registration (6.1) are now DONE, landing exactly the seam this row's original filing fenced off; drag-to-sell consumption and `InqAcceptability`-gated sell UI remain Slice 6b/6c territory. | A player can browse, select, examine, and BUY (Slice 6.3) — the only remaining unbuilt transaction is Sell. Clicking "Buying"/"Selling" still shows an empty page with no error or explanation, matching "present but does nothing" rather than a disabled/hidden affordance. The dropdown's closed-state button face is missing its separate arrow-cap glyph — a minor visual gap, not a functional one; the open popup itself is pixel-faithful to the authored template. | `gmVendorUI::OpenVendor` pc:203650 (`m_itemsUI`/`m_buyUI`/`m_sellUI` construction, `PostInit` pc:199906, `m_last_sale` reset pc:203790/203800); `VendorBuyUI::VendorBuyUI` pc:199717; `VendorSellUI::VendorSellUI` pc:199753; `VendorProfile::InqAcceptability` pc:484768-484797; `UIElement_Menu::MakePopup` pc:120705-120764, `::Initialize` pc:120789-120828; `VendorItemsUI::UpdateItemsUI` pc:202539-202820; `VendorItemsUI::UpdateItemsList` pc:201029-201190; `ItemHolder::GetObjectSplitSize` pc:401465-401477; `gmToolbarUI::HandleSelectionChanged` pc:198740-198790 (mask `0xDC41CB0` at pc:198784); `ACCWeenieObject::GetObjectName` pc:409056-409132; `docs/research/2026-08-08-slice5-vendor-browse-research.md` §B.4, §D | | AP-162 | **NARROWED 2026-08-09 (Opus review of `92ea3977`, finding F1) — the "Buy All" half of this row CLOSES.** `VendorUiController.BuyAllButtonPressed` now ports all four of retail's client-side pre-send guards (pyreal affordability `pc:204017`, alt-currency affordability `pc:204032`, container-slot capacity `pc:204053`, item-slot capacity `pc:204067`) — see `ComputeBuyTransactionValue`/`ComputeBuySlotsNeeded`/`CountPlayerContents`, each guard returning with staging fully intact and retail's own exact notice string (`"You don't have enough money"` at `0x007b57b4`, `"You must empty some slots in your backpack first"` at `0x007b5750`, both byte-recovered). The container-vs-item slot CLASSIFICATION this port uses (`ItemType.Container` instead of retail's bitfield/capacity test) is its own new, narrower approximation — filed separately as AP-168 rather than folded in here. Only `TryBuy`'s single-item Buy path (Items tab's own Buy button, and the Buying tab's "Buy Item") remains WITHOUT a client-side pre-check — the risk/oracle columns below now describe that one remaining case, not both. **EXTENDED 2026-08-09 (Slice 6b) — the same omission now also covers "Buy All".** `ItemInteractionController.TryBuyAll` (the batched-send path `VendorUiController.BuyAllButtonPressed` calls) sends unconditionally too, without porting retail's `pc:204017/204032/204053/204067` affordability/pack-capacity pre-checks for the MULTI-item case either — the same latency-not-correctness tradeoff this row already documents for the single-item path, extended rather than duplicated into a second row; retiring this row should port both the single- and batched-send pre-checks together. **Filed 2026-08-09, Slice 6.3 (buy wire + button).** Retail's `BuySingleItem` (`pc:201661`) performs TWO client-side pre-checks before ever sending `CM_Vendor::Event_Buy`: (a) an affordability check against `this->m_totalValue` (pyreal) or `shopVendorProfile->trade_num - m_last_sale` (alt-currency), showing a LOCAL string via `ECM_UI::SendNotice_DisplayStringInfo` and returning without sending anything on failure (`pc:201686-201717`); (b) a pack/container-capacity pre-check (`pc:201730-201746`) mirroring the server's own check. acdream's `ItemInteractionController.TryBuy` sends unconditionally once the shared use/inventory gate is free — no client-side affordability or capacity check runs before dispatch. Every refused purchase pays a full round-trip (send → server rejects → `UseDone`/`GameEventInventoryServerSaveFailed`) instead of failing instantly and silently client-side. **Swept 2026-08-09 (F4 review fix):** `TryBuy` now also checks whether `sendBuy` actually reached a live, in-world session before marking the reservation dispatched — an orthogonal reservation-leak bug fix (no session ever produced a stray permanent busy-lock), not an affordability/capacity check; this row's scope and residual are unchanged. | `src/AcDream.App/UI/ItemInteractionController.cs` (`TryBuy`) | The research doc's own open question 1 (`docs/research/2026-08-08-slice6-vendor-transactions-research.md`) recommends deferring this: the server is authoritative either way (ACE re-validates both affordability and capacity server-side — `Vendor.BuyItems_ValidateTransaction`, `Vendor.cs:431-571`), so omitting the client pre-check is a LATENCY/UX gap, not a correctness one — a refused purchase still fails cleanly, just one round-trip later than retail. | A player attempting to buy something they cannot afford or have no room for sees the failure arrive after a network round-trip instead of instantly; against a well-behaved ACE server no purchase can succeed that retail's pre-check would have blocked, so no transaction outcome differs — only its latency. Retiring this row means porting `BuySingleItem`'s two pre-check branches (`pc:201686-201746`) into `TryBuy` before dispatch. | `gmVendorUI::BuySingleItem` pc:201661/0x004C2820 (affordability pc:201686-201717, capacity pc:201730-201746); `Vendor.BuyItems_ValidateTransaction` (`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:431-571`); `docs/research/2026-08-08-slice6-vendor-transactions-research.md` §D point 4, Open question 1 | diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index 0aa20007..d482a5bf 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -265,7 +265,9 @@ row-template resolver already takes (`RetailUiRuntime.MountCharacter`), and set the list box's authored 24px row height so wheel/line scroll lands row-aligned. -**CT4 — Header identity block. CODE-COMPLETE 2026-08-24.** Retail composition: name; " +**CT4 — Header identity block. REVIEW-CLOSED 2026-08-25: landed `ed652ed8`, +Opus dual-lens review (2 blockers + 5 should-fix, all applied), fix round +applied 2026-08-25.** Retail composition: name; " "; PK status line — authored fonts/colors (pure white per probe), live refresh on display-title change and PK status, identical on Attributes AND Skills pages. Level color from the @@ -275,7 +277,10 @@ authored element. Retires the rest of AP-109's UI half. already covers both Attributes/Skills page copies — `CharacterStatController` binds the SAME physically-visible container (contentPage = the Attributes page chain) for both tabs; the Skills-page duplicate header subtree is never -shown (a test now pins this: `Bind_HeaderElements_UseVisibleAttributesPageWhenIdsAreDuplicated`). +shown (pinned by `Bind_HeaderElements_UseVisibleAttributesPageWhenIdsAreDuplicated`, +a test that PREDATES CT4 — corrected at the CT4 fix round below, since the +original wording here implied CT4 wrote it fresh; the pre-existing test did +not cover `PkStatusId` until the fix round extended it). All four header identity elements (Name/Heritage/PkStatus/Level) switched from hand-picked `Body`/`Gold` runtime colors to the widget's own authored `DefaultColor` (`LabelAuthoredColor`), matching CT1's live-DAT pin exactly — @@ -319,6 +324,60 @@ key-by-status resolution including a combined-flag case, no-resolver ⇒ null, Level null-vs-present, title resolution + live refresh on both title events + unsubscribe-on-dispose, luminance Int64 read-through). +**CT4 fix round (Opus dual-lens review, 2026-08-25).** 2 BLOCKERS: (1) the +luminance caption/value strings were RECOVERED by PE-byte-decoding the raw +retail binary (caption UTF-16 `"Luminance:"` at `@0x007c3dd4`, value narrow +`"%s / %s"` at `@0x007c3dcc`, both immediately following +`gmStatManagementUI::UpdatePKStatus`'s own vftable slots — the CT4 landing's +"could not be recovered" claim is FALSIFIED), so the pair now binds real +text (each number formatted through a new shared `FormatXp` helper — +`.ToString("N0", InvariantCulture)`, also now used by Total XP / XP-to-next- +level, replacing their un-invariant `.ToString("N0")`), and the hide path +switched from `Visible = false` to retail's own `UIElement_Text::ClearAllText` +mechanism (`@0x004f0e31`/`@0x004f0e3c` — empty the LinesProvider, leave +layout); (2) `CharacterIdentityText.StripLeadingArticle` is deleted — retail +`AppendText`s the resolved title VERBATIM (`@0x004f0990`), and 26 real ACE +`CharacterTitle` entries begin with "The", so every one of them was being +mangled; the dead `CharacterSheet.Race` fallback (no retail producer — the +`InqGenderHeritageDisplay` creature-type argument is a hardcoded literal `0` +at `@0x004f08db`) is deleted alongside it. 5 SHOULD-FIX: (3) the PK line now +classifies off the live `ClientObject.PublicWeenieBitfield` PWD bits +(`0x20`/`0x02000000`, `ACCWeenieObject::IsPK`/`IsPKLite` +`@0x0058c8b0`/`@0x0058c8a0`) instead of a bitwise test against raw +PropertyInt 134 — PropertyInt 134 carries ACE's own `PlayerKillerStatus` +enum bit layout, not the PWD layout, so the deleted `0x4 | 0x8` combined-flag +test case asserted a non-retail answer (PropertyInt 134 already drives the +correct PWD bits via `PlayerKillerStatusBitfield.Apply`, so this is a +re-source, not new wiring); (4) the register's AP-109 row restores CT3's +Titles-page narrowing paragraph (CT4's edit had compressed it to a bare +pointer phrase), corrects the rank-prefix item's source to PropertyInt +`0x1E` (`AllegianceRank`) read live off the qualities bundle — NOT +`RuntimeAllegianceState`, which is a DIFFERENT UI's (`SocialAllegiancePageController`) +own documented substitute — corrects the title-table size from the +originally-estimated 22 functions/~200 strings to the ACTUAL 17 +functions/~170 strings (`AllegianceSystem::GetTitle`'s dispatch switch read +directly: Gearknight/Tumerok author only a male function reused both ways, +Lugian only a female one, and Olthoi/OlthoiAcid have none), and downgrades +the row's evidence claim to "synthetic-layout binding tests plus a small +number of InstalledDat string/DID pins" rather than implying a +connected/live gate; (5) `CharacterPanelLiveDatTests.HeaderElements_AuthorExpectedFontsAndColors` +gains the luminance pair's own occurrence-count + font/color pins, matching +the pattern every other header id already uses. Also landed this round: an +InstalledDat pin (`GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain`) +proving `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` +match the live retail `EnumMapper` chain (master map category 1 → +`ClientEnumToID[0x10000001]`/`[0x10000002]` → EnumMapper DIDs +`0x2200000A`/`0x2200000B`) byte-exact, including the two entries (10 +"Penumbraen", 12 "Olthoi") the review had flagged as unverified guesses — +both are correct; the mechanism divergence (hardcoded table vs. live DAT +read) is filed as AP-235, pointing CT5 at the ALREADY-EXISTING generic +`RetailDataIdResolver.Resolve` helper (not a new "GetDIDByEnum helper" to +write) as the unification seam; `RetailAppraisalNameResolver.ResolveHeritage`'s +independent re-implementation of the same three overrides is noted there +too, for CT5. `CharacterSheetProvider.BuildSheet`'s level read switched from +a `GetInt` + `Ints.ContainsKey` double dictionary lookup to one +`TryGetValue`. + **CT5 — Row alignment + value gutter.** Reconcile our hand-built attribute/skill rows with the authored row templates from CT1: icon placement, name/value columns, the authored right margin that reserves diff --git a/src/AcDream.App/UI/Layout/CharacterIdentityText.cs b/src/AcDream.App/UI/Layout/CharacterIdentityText.cs index f57a0d41..2aab47c0 100644 --- a/src/AcDream.App/UI/Layout/CharacterIdentityText.cs +++ b/src/AcDream.App/UI/Layout/CharacterIdentityText.cs @@ -12,47 +12,72 @@ namespace AcDream.App.UI.Layout; /// matched it. /// /// -/// Name-line ruling (CT4, 2026-08-24). Retail's NAME line +/// Name-line ruling (CT4, 2026-08-24; corrected at the CT4 fix round, +/// 2026-08-25 — AP-109). Retail's NAME line /// (AllegianceData::GetFullName @0x005b6950) prefixes an allegiance /// RANK title ("<RankTitle> <Name>", same space separator, PE-read /// @data_794098) when AllegianceSystem::GetTitle(rank, heritage, gender) -/// @0x005b8dd0 resolves one. -/// (Campaign FA) DOES carry the local player's own rank -/// (ApplyUpdate's _rank, seeded by 0x0020 -/// AllegianceUpdate — always the local tree), so the DATA half exists. -/// The STRING half does not: GetTitle dispatches on heritage×gender -/// into 22 separate functions (GetAluvianMaleTitle @0x005b7bc0, -/// GetAluvianFemaleTitle @0x005b7cd0, … one per heritage/gender pair -/// through Undead), each a rank-indexed switch over ~10 HARDCODED literal -/// strings (Aluvian male: "Yeoman"/"Baronet"/"Baron"/"Reeve"/"Thane"/ -/// "Ealdor"/"Duke"/"Aetheling"/"King"/"High King" — verbatim from the -/// decomp, not DAT-resolved, not guessed) — roughly 200 title strings -/// total. That is not "reasonable size" for this slice on top of its other -/// four items, so 's Name label -/// ships the PLAIN-NAME case only (matching the owner's own retail -/// screenshot, a rankless character, and every current test character). -/// The missing rank-prefix path is registered -/// (docs/architecture/retail-divergence-register.md) rather than -/// silently omitted. +/// @0x005b8dd0 resolves one. The RANK value is PropertyInt 0x1E +/// (AllegianceRank) read LIVE off the qualities bundle +/// (CBaseQualities::InqInt(qualities, 0x1e)) — NOT +/// . That state +/// class carries a numerically-equivalent rank for a DIFFERENT UI +/// (SocialAllegiancePageController, which has no qualities-bundle +/// access of its own); +/// already reads every other header property straight off +/// props.GetInt(...), so a future port reads +/// props.GetInt(0x1Eu) directly instead. The STRING half is missing: +/// GetTitle's own dispatch switch (read directly, not estimated) has +/// exactly 17 Get*Title functions, not 22 — Gearknight/Tumerok author +/// only a MALE function (reused for both genders' dispatch branches) and +/// Lugian only a FEMALE one (likewise reused both ways), so 11 heritages +/// produce 17 functions; Olthoi/OlthoiAcid have none at all (the dispatch's +/// own unsigned range check excludes heritage ids 12/13). Each function is a +/// rank-indexed switch over ~10 HARDCODED literal strings (Aluvian male: +/// "Yeoman"/"Baronet"/"Baron"/"Reeve"/"Thane"/"Ealdor"/"Duke"/"Aetheling"/ +/// "King"/"High King" — verbatim from the decomp, not DAT-resolved, not +/// guessed) — roughly 170 title strings total. That is not "reasonable +/// size" for one slice on top of its other work, so +/// 's Name label ships the +/// PLAIN-NAME case only (matching the owner's own retail screenshot, a +/// rankless character, and every current test character). The missing +/// rank-prefix path is registered +/// (docs/architecture/retail-divergence-register.md, AP-109) rather +/// than silently omitted. /// internal static class CharacterIdentityText { public const uint GenderPropertyId = 0x71u; public const uint HeritageGroupPropertyId = 0xBCu; + /// + /// CT4 fix round (2026-08-25, BLOCKER 2): retail's AppendText at + /// @0x004f0990 appends the resolved CharacterTitleTable + /// string VERBATIM — no article stripping. 26 real ACE + /// CharacterTitle entries begin with "The" (e.g. "The Noob"), so + /// the former StripLeadingArticle call mangled every one of them. + /// Heritage also drops its fallback the + /// same round: InqGenderHeritageDisplay's third argument + /// (creature type) is a hardcoded literal 0 at + /// @0x004f08db, not sourced from any producer — retail has no + /// "race" input to this line at all. + /// public static string StatHeaderLine(CharacterSheet sheet) { - string? heritage = !string.IsNullOrWhiteSpace(sheet.Heritage) - ? sheet.Heritage - : sheet.Race; - - string? title = StripLeadingArticle(sheet.Title); - if (string.IsNullOrWhiteSpace(sheet.Gender)) - return Join(heritage, title); - return Join(sheet.Gender, heritage, title); + return Join(sheet.Heritage, sheet.Title); + return Join(sheet.Gender, sheet.Heritage, sheet.Title); } + /// + /// Retail: AppraisalSystem::InqGenderDisplayName @0x005b47c0 → + /// EnumMapper::GetString(0x10000001, gender, ...) → + /// DBObj::GetDIDByEnum (master map category 1, EnumMapper DID + /// 0x2200000A) — a LIVE DAT read. This table is a hardcoded C# + /// mechanism substitute (register row AP-235); its content is verified + /// byte-exact against the live EnumMapper by the InstalledDat pin + /// CharacterPanelLiveDatTests.GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain. + /// public static string? GenderDisplayName(int gender) => gender switch { 1 => "Male", @@ -60,6 +85,19 @@ internal static class CharacterIdentityText _ => null, }; + /// + /// Retail: AppraisalSystem::InqHeritageGroupDisplayName @0x005b4710 + /// hardcodes ids 2/5/0xd to "Gharu'ndim"/"Umbraen"/"Olthoi", else falls + /// through to EnumMapper::GetString(0x10000002, heritage, ...) → + /// DBObj::GetDIDByEnum (master map category 1, EnumMapper DID + /// 0x2200000B) — a LIVE DAT read whose raw entries are the + /// internal names ("Gharundim", "Shadowbound", "OlthoiAcid" for those + /// same three ids). This table is a hardcoded C# mechanism substitute + /// (register row AP-235); every entry, including the ones the CT4 review + /// flagged as unverified guesses (10 "Penumbraen", 12 "Olthoi"), is + /// verified byte-exact against the live EnumMapper chain by + /// 's sibling InstalledDat pin. + /// public static string? HeritageGroupDisplayName(int heritageGroup) => heritageGroup switch { 1 => "Aluvian", @@ -84,14 +122,4 @@ internal static class CharacterIdentityText .Where(p => !string.IsNullOrWhiteSpace(p)) .Select(p => p!.Trim())); } - - private static string? StripLeadingArticle(string? title) - { - if (string.IsNullOrWhiteSpace(title)) return null; - - string trimmed = title.Trim(); - return trimmed.StartsWith("the ", System.StringComparison.OrdinalIgnoreCase) - ? trimmed[4..] - : trimmed; - } } diff --git a/src/AcDream.App/UI/Layout/CharacterSheet.cs b/src/AcDream.App/UI/Layout/CharacterSheet.cs index 08c84b91..711c72b7 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheet.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheet.cs @@ -37,7 +37,15 @@ public sealed class CharacterSheet /// Gender display string, e.g. "Female". Null = omit. public string? Gender { get; init; } - /// Race string, e.g. "Aluvian". Null = omit. + /// Race string, e.g. "Aluvian". Null = omit. + /// CT4 fix round (2026-08-25): no longer read by + /// — retail's + /// InqGenderHeritageDisplay creature-type argument is a hardcoded + /// literal 0 (@0x004f08db), with no producer for a "race" + /// value distinct from . Left in place as an + /// unpopulated field rather than deleted, since no current producer sets + /// it either; a future consumer needing a genuinely distinct race value + /// should confirm a real retail source exists first. public string? Race { get; init; } /// Heritage group display string, e.g. "Aluvian". Null = omit. diff --git a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs index 32b20f60..85844626 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs @@ -151,9 +151,11 @@ public sealed class CharacterSheetProvider // needs to distinguish "absent" from "present but zero", so this // stays a raw dictionary probe rather than GetInt's zero-defaulting // helper. The XP-curve math below still wants a concrete int, so it - // keeps using the 0-defaulted local. - int level = props.GetInt(0x19u); - int? displayLevel = props.Ints.ContainsKey(0x19u) ? level : null; + // keeps using the 0-defaulted local. CT4 fix round: one TryGetValue + // instead of a ContainsKey-then-indexer double lookup. + bool hasLevel = props.Ints.TryGetValue(0x19u, out int levelValue); + int level = hasLevel ? levelValue : 0; + int? displayLevel = hasLevel ? levelValue : null; long totalXp = props.GetInt64(1u); long unassignedXp = props.GetInt64(UnassignedXpPropertyId); var xp = ComputeLevelXp(level, totalXp); @@ -173,7 +175,7 @@ public sealed class CharacterSheetProvider Title = _titles is not null && _resolveDisplayTitle is not null ? _resolveDisplayTitle(_titles.DisplayTitleId) : null, - PkStatus = PkStatusText(props.GetInt(134u, 0), _resolveUiString), + PkStatus = PkStatusText(CurrentPlayerBitfield(), _resolveUiString), TotalXp = totalXp, XpToNextLevel = xp.toNext, XpFraction = xp.fraction, @@ -270,6 +272,25 @@ public sealed class CharacterSheetProvider : _localPlayer.Properties; } + /// + /// CT4 fix round (2026-08-25, SHOULD-FIX 3): the local player's live + /// PublicWeenieDesc bitfield — the ONLY source retail's PK line + /// actually reads (ACCWeenieObject::IsPK/IsPKLite, PWD bits + /// 0x20/0x02000000). Only available once the live + /// has arrived (CreateObject); the PlayerDescription snapshot on + /// carries no bitfield, so this returns 0 + /// (retail's own "neither" → NPK default) until then — matching + /// 's + /// precedent read. + /// + private uint CurrentPlayerBitfield() + { + uint guid = _playerGuid(); + return guid != 0u && _objects.Get(guid) is { } player + ? player.PublicWeenieBitfield ?? 0u + : 0u; + } + private sealed class ChangeBinding : IDisposable { private CharacterSheetProvider? _owner; @@ -565,28 +586,48 @@ public sealed class CharacterSheetProvider value > long.MaxValue ? long.MaxValue : (long)value; /// - /// Campaign CT slice CT4: gmStatManagementUI::UpdatePKStatus - /// (0x004f00a0) — IsPK() tested first, then IsPKLite(), - /// else "neither" resolves the NPK string (retail always shows exactly - /// one of the three; there is no hidden/omitted case). ACE's - /// PlayerKillerStatus is a [Flags] enum (PK=0x04, - /// PKLite=0x40) — a bitwise test matches the derived-boolean retail - /// semantics; the prior exact-equality switch silently showed nothing - /// for any combined-flag value. Text resolves through StringTable - /// 0x23000001 by key () — no hardcoded - /// English fallback; a null resolver or a resolution miss both leave - /// the line empty, matching the CT4 contract's "no invented English". + /// Campaign CT slice CT4, re-sourced at the CT4 fix round (2026-08-25, + /// SHOULD-FIX 3): gmStatManagementUI::UpdatePKStatus + /// (0x004f00a0) — eax->vtable->IsPK() tested first, then + /// IsPKLite(), else "neither" resolves the NPK string (retail + /// always shows exactly one of the three; there is no hidden/omitted + /// case). ACCWeenieObject::IsPK/IsPKLite + /// (@0x0058c8b0/@0x0058c8a0) read the live + /// PublicWeenieDesc BITFIELD directly — bit 5 (0x20) and + /// bit 0x19 (0x02000000) — NOT PropertyInt 134 + /// (PlayerKillerStatus) bitwise-tested against ACE's own enum + /// values (a prior version of this method did that; ACE's enum bit + /// layout is not the PWD bit layout, so a combined-flag PropertyInt + /// value like 0x4 | 0x8 would misclassify). PropertyInt 134 + /// already drives the correct bits via + /// — see + /// — so this method + /// only needs to read , + /// matching the precedent read at + /// . + /// Text resolves through StringTable 0x23000001 by key + /// () — no hardcoded English fallback; a + /// null resolver or a resolution miss both leave the line empty, + /// matching the CT4 contract's "no invented English". /// - private static string? PkStatusText(int status, Func? resolveUiString) + private static string? PkStatusText(uint publicWeenieBitfield, Func? resolveUiString) { - string key = (status & 0x4) != 0 + string key = (publicWeenieBitfield & PkPwdBit) != 0u ? "ID_StatManagement_Header_PKStatus_PK" - : (status & 0x40) != 0 + : (publicWeenieBitfield & PkLitePwdBit) != 0u ? "ID_StatManagement_Header_PKStatus_PKL" : "ID_StatManagement_Header_PKStatus_NPK"; return resolveUiString?.Invoke(key); } + /// PWD bit 5 — ACCWeenieObject::IsPK @0x0058c8b0: + /// (bitfield >> 5) & 1. + private const uint PkPwdBit = 0x20u; + + /// PWD bit 0x19 (25) — ACCWeenieObject::IsPKLite @0x0058c8a0: + /// (bitfield >> 0x19) & 1. + private const uint PkLitePwdBit = 0x02000000u; + /// Unenchanted base attribute value (Ranks + Start). Used for /// — the retail /// footer-title delta parenthetical compares this against diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index 8c62ddb4..1aa203f3 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -74,13 +74,20 @@ public static class CharacterStatController /// (m_pLuminanceLabelText/m_pLuminanceText), shown only past level 200 /// with nonzero MaximumLuminance — see /// gmStatManagementUI::UpdateExperience (0x004f0a70)'s luminance - /// branch. The label's own retail caption/value StringInfo could not be - /// recovered this slice (its SetText calls resolve through a - /// Binary-Ninja-mislabeled data pointer, not a StringTable key — see the - /// Bind method's own remarks); only the show/hide gate is wired here. + /// branch. CT4 fix round (2026-08-25, BLOCKER 1): the caption/value + /// SetText calls PE-byte-decoded from the gmStatManagementUI + /// vftable-adjacent data region — caption UTF-16 "Luminance:" at + /// @0x007c3dd4, value narrow "%s / %s" at + /// @0x007c3dcc — are now bound; see the Bind method's own + /// remarks. public const uint LuminanceLabelId = 0x100005C5u; public const uint LuminanceValueId = 0x100005C6u; + /// Retail literal "Luminance:" — see + /// 's remarks for the PE-byte-decode + /// citation. + private const string LuminanceCaption = "Luminance:"; + // ── Footer STATE-A container id ────────────────────────────────────────── // 0x10000240 is the "nothing selected" footer group. Its children (0x1000024E label row, // 0x10000242–0x10000245 labels+values) are the correct State-A versions with wider @@ -340,7 +347,7 @@ public static class CharacterStatController // TotalXpLabel (16px from dat) + TotalXp (16px from dat): pass null → keep dat font. LabelLeft(layout, contentPage, TotalXpLabelId, null, Body, static () => "Total Experience (XP):"); - LabelRight(layout, contentPage, TotalXpId, null, Body, () => data().TotalXp.ToString("N0")); + LabelRight(layout, contentPage, TotalXpId, null, Body, () => FormatXp(data().TotalXp)); // XP-to-level meter fill (gmStatManagementUI::UpdateExperience 0x004f0a70). // Fix 5: child elements 0x10000237 (label) and 0x10000238 (value) are now built by @@ -389,35 +396,51 @@ public static class CharacterStatController xpValue.RightAligned = true; xpValue.OneLine = true; xpValue.Padding = 0f; // avoid scroll clip - xpValue.LinesProvider = () => new[] { new UiText.Line(data().XpToNextLevel.ToString("N0"), Body) }; + xpValue.LinesProvider = () => new[] { new UiText.Line(FormatXp(data().XpToNextLevel), Body) }; } } - // ── Luminance pair (0x100005C5/C6) — CT4 item 5 ─────────────────────── + // ── Luminance pair (0x100005C5/C6) — CT4 item 5, text bound at the ── + // ── CT4 fix round (2026-08-25, BLOCKER 1) ──────────────────────────── // gmStatManagementUI::UpdateExperience (0x004f0a70): InqInt64(6) // (AvailableLuminance) and InqInt64(7) (MaximumLuminance) are read - // unconditionally, but the pair is hidden — UIElement_Text::ClearAllText - // on BOTH m_pLuminanceLabelText and m_pLuminanceText — whenever - // "InqInt(0x19) < 0xc8 (200) || MaximumLuminance == 0". Only the - // gate is ported this slice: the label's caption and the value's - // composed "available / maximum" string both resolve through a - // SetText call whose source string BN mislabels as a vftable slot - // (not a StringTable key like the PK line) — recovering the exact - // literal needs a PE-byte-decode pass this slice didn't budget for - // (register row: AP-109 narrows to exactly this). Content is - // intentionally left unbound (blank) rather than guessed; only - // Visible is toggled, so a level-200+ character sees an empty - // (not wrong) pair until a follow-up slice fills it in. - UiElement? luminanceLabel = FindElementByDatId(layout, contentPage, LuminanceLabelId); - UiElement? luminanceValue = FindElementByDatId(layout, contentPage, LuminanceValueId); - void RefreshLuminanceVisibility() + // unconditionally; the pair is hidden — UIElement_Text::ClearAllText + // (@0x004f0e31/@0x004f0e3c) on BOTH m_pLuminanceLabelText and + // m_pLuminanceText — whenever "InqInt(0x19) < 0xc8 (200) || + // MaximumLuminance == 0". ClearAllText empties the widget's text and + // leaves layout/Visible untouched, so this binds the SAME way every + // other dynamic label in this method does: a LinesProvider that + // re-reads data() on every draw and returns an EMPTY line set when + // the gate is closed (retail's ClearAllText) or the resolved content + // when it is open — no separate "refresh" call is needed, and no + // Visible flag is touched. Retail's SetText calls (recovered by + // PE-byte-decoding the gmStatManagementUI vftable-adjacent data + // region, since Binary Ninja mislabels the two string pointers as + // vftable slots rather than a StringTable key like the PK line): + // caption = literal "Luminance:" (UTF-16 @0x007c3dd4); value = + // narrow "%s / %s" (@0x007c3dcc) with (available, maximum) in that + // order, both formatted through ExperienceSystem::XPToString — ported + // as the shared FormatXp helper below (the same one Total XP / XP-to- + // next-level already use). + bool LuminanceVisible(CharacterSheet sheet) => + sheet.Level is int lvl && lvl >= 200 && sheet.MaximumLuminance != 0; + + if (FindTextByDatId(layout, contentPage, LuminanceLabelId) is UiText luminanceLabel) { - var sheet = data(); - bool visible = sheet.Level is int lvl && lvl >= 200 && sheet.MaximumLuminance != 0; - if (luminanceLabel is not null) luminanceLabel.Visible = visible; - if (luminanceValue is not null) luminanceValue.Visible = visible; + luminanceLabel.LinesProvider = () => LuminanceVisible(data()) + ? new[] { new UiText.Line(LuminanceCaption, luminanceLabel.DefaultColor) } + : Array.Empty(); + } + if (FindTextByDatId(layout, contentPage, LuminanceValueId) is UiText luminanceValue) + { + luminanceValue.LinesProvider = () => + { + var sheet = data(); + if (!LuminanceVisible(sheet)) return Array.Empty(); + string text = $"{FormatXp(sheet.AvailableLuminance)} / {FormatXp(sheet.MaximumLuminance)}"; + return new[] { new UiText.Line(text, luminanceValue.DefaultColor) }; + }; } - RefreshLuminanceVisibility(); // The tab visuals are already retained in the imported LayoutDesc. Controllers // bind only click behavior and the active Open/Closed state below. @@ -708,11 +731,10 @@ public static class CharacterStatController } RefreshActiveRaiseButtons(); - // CT4: the luminance gate reads Level/MaximumLuminance off the - // CURRENT sheet, so it must re-run on every sheet-changed refresh - // (level-up, a luminance-award quality change), not only at bind - // time. - RefreshLuminanceVisibility(); + // CT4 fix round: the luminance pair's LinesProvider re-reads + // data() on every draw (same as every other dynamic label here), + // so no explicit refresh call is needed for a level-up or a + // luminance-award quality change. } return () => RefreshAfterRaise(null); @@ -2066,6 +2088,19 @@ public static class CharacterStatController } } + /// + /// Retail-equivalent of ExperienceSystem::XPToString + /// (sprintf("%I64d", value)GetNumberFormatA's + /// locale-grouped-decimal formatting) — shared by every field that + /// formats a retail XP-shaped 64-bit count: Total XP, XP-to-next-level, + /// and (CT4 fix round, 2026-08-25) the luminance available/maximum pair. + /// Retail text is US-formatted for everyone (the project's locale- + /// independence rule), so this is InvariantCulture, not + /// CurrentCulture — the pre-CT4-fix-round call sites used a bare + /// .ToString("N0"), which silently followed the host OS locale. + /// + private static string FormatXp(long value) => value.ToString("N0", CultureInfo.InvariantCulture); + /// /// Same binding shape as , but the per-line color is /// read from the widget's own — the @@ -2077,7 +2112,17 @@ public static class CharacterStatController /// wins" here is both simpler and more correct than hand-picking a /// runtime constant — the same precedent /// 's row/display text already - /// set (rowText.DefaultColor). + /// set (rowText.DefaultColor). CT4 fix-round consistency note + /// (2026-08-25): this helper unconditionally forces + /// Centered = true/OneLine = true, which is correct for + /// the four elements it is actually called on (Name/Heritage/PkStatus/ + /// Level, all centered in the DAT), but would be WRONG for a + /// left/right-justified authored element (e.g. the luminance pair, + /// which is deliberately bound with its own inline LinesProvider below + /// rather than through this helper, precisely to preserve its authored + /// Left/Right justification). Left as-is rather than parameterizing + /// Centered/OneLine, since no current caller needs the non-centered + /// case — a future caller that does should not reuse this helper as-is. /// private static void LabelAuthoredColor(ImportedLayout layout, UiElement? scope, uint id, UiDatFont? datFont, Func text) { diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs index 5a87693c..032eb077 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs @@ -110,6 +110,28 @@ public sealed class CharacterPanelLiveDatTests Assert.Equal(2, xpValueOccurrences.Count); foreach (var xpValue in xpValueOccurrences) Assert.Equal(0x40000000u, xpValue.FontDid); + + // CT4 fix round (2026-08-25, SHOULD-FIX 5): the luminance pair's own + // binding-seam pin — same occurrence-count pattern as every other + // header id above (Attributes-page + Skills-page duplicate chains). + // Font/color ground truth from + // docs/research/2026-08-24-campaign-ct-dat-ground-truth.md: both + // elements author font 0x40000000, pure white, no outline. + var luminanceLabelOccurrences = Flatten(tree!).Where(e => e.Id == CharacterStatController.LuminanceLabelId).ToList(); + Assert.Equal(2, luminanceLabelOccurrences.Count); + foreach (var luminanceLabel in luminanceLabelOccurrences) + { + Assert.Equal(0x40000000u, luminanceLabel.FontDid); + Assert.Equal(Vector4.One, luminanceLabel.FontColor); + } + + var luminanceValueOccurrences = Flatten(tree!).Where(e => e.Id == CharacterStatController.LuminanceValueId).ToList(); + Assert.Equal(2, luminanceValueOccurrences.Count); + foreach (var luminanceValue in luminanceValueOccurrences) + { + Assert.Equal(0x40000000u, luminanceValue.FontDid); + Assert.Equal(Vector4.One, luminanceValue.FontColor); + } } /// @@ -388,6 +410,79 @@ public sealed class CharacterPanelLiveDatTests Assert.Equal("War Mage", resolved); } + /// + /// CT4 fix round item 6: retail resolves gender via + /// AppraisalSystem::InqGenderDisplayName @0x005b47c0 and heritage + /// via InqHeritageGroupDisplayName @0x005b4710, both through the + /// STATIC EnumMapper::GetString(uint32_t enumValue, uint32_t queryId, + /// PStringBase<char>*) @0x0041ac40 overload, which itself calls + /// DBObj::GetDIDByEnum(&did, enumValue, 1) — master map + /// (0x25000000) category-1 sub-map (0x25000001), then + /// ClientEnumToID[0x10000001] (gender) / [0x10000002] + /// (heritage) resolve to EnumMapper DIDs 0x2200000A / + /// 0x2200000B. Both mappers' IdToStringMap entries are used + /// VERBATIM as final display text (no further StringTable hash step, per + /// the decomp) EXCEPT heritage ids 2/5/0xd, which retail hardcodes to + /// "Gharu'ndim"/"Umbraen"/"Olthoi" instead of the raw + /// "Gharundim"/"Shadowbound"/"OlthoiAcid" internal names. This pin proves + /// every / + /// table + /// entry matches that exact algorithm against the live installed DAT — + /// catching a wrong guess (none found: ids 10 "Penumbraen" and 12 + /// "Olthoi", flagged as unverified guesses in the CT4 review, both come + /// back byte-exact). See the register's CT4 mechanism-divergence row for + /// why the tables stay hardcoded rather than reading this chain live. + /// + [InstalledDatFact] + public void GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain() + { + using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + + bool gotMaster = dats.Portal.TryGet( + (uint)dats.Portal.Header.MasterMapId, out var master); + Assert.True(gotMaster); + Assert.NotNull(master); + Assert.True(master!.ClientEnumToID.TryGetValue(1u, out uint categoryDid)); + + bool gotCategoryMap = dats.Portal.TryGet(categoryDid, out var categoryMap); + Assert.True(gotCategoryMap); + Assert.NotNull(categoryMap); + + Assert.True(categoryMap!.ClientEnumToID.TryGetValue(0x10000001u, out uint genderDid)); + Assert.Equal(0x2200000Au, genderDid); + Assert.True(dats.Portal.TryGet(genderDid, out var genderMapper)); + Assert.NotNull(genderMapper); + + foreach (var (id, raw) in genderMapper!.IdToStringMap) + { + string? expected = raw.Value == "Invalid" ? null : raw.Value; + Assert.Equal(expected, CharacterIdentityText.GenderDisplayName((int)id)); + } + + Assert.True(categoryMap.ClientEnumToID.TryGetValue(0x10000002u, out uint heritageDid)); + Assert.Equal(0x2200000Bu, heritageDid); + Assert.True(dats.Portal.TryGet(heritageDid, out var heritageMapper)); + Assert.NotNull(heritageMapper); + + // AppraisalSystem::InqHeritageGroupDisplayName's three hardcoded + // overrides (@0x005b4718/0x005b4732/0x005b474c) — applied ahead of + // the raw EnumMapper text, exactly like the retail branch order. + var overrides = new Dictionary + { + [2u] = "Gharu'ndim", + [5u] = "Umbraen", + [0xDu] = "Olthoi", + }; + + foreach (var (id, raw) in heritageMapper!.IdToStringMap) + { + string? expected = overrides.TryGetValue(id, out string? overridden) + ? overridden + : raw.Value == "Invalid" ? null : raw.Value; + Assert.Equal(expected, CharacterIdentityText.HeritageGroupDisplayName((int)id)); + } + } + /// /// Campaign CT slice CT4: gmStatManagementUI::UpdatePKStatus /// (0x004f00a0) resolves its three-way PK status text through diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs index 94931d08..623f2da6 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs @@ -431,16 +431,27 @@ public sealed class CharacterSheetProviderTests /// which of the three ID_StatManagement_Header_PKStatus_* keys /// was selected for each ACE PlayerKillerStatus value — the CT4 /// contract's "PK line resolves the three keys by status". + /// + /// CT4 fix round (2026-08-25, SHOULD-FIX 3): drives the wire property + /// through — the SAME + /// path the live 0x02CE/0x02CD PropertyInt handler uses, + /// which applies to + /// — instead of writing + /// PropertyInt 134 directly into the bundle. Retail's PK line reads the + /// PWD bits (ACCWeenieObject::IsPK/IsPKLite), never + /// PropertyInt 134 itself; the deleted combined-flag case (0x4 | + /// 0x8) asserted a non-retail bitwise-on-property mapping — ACE only + /// ever sends an EXACT PlayerKillerStatus enum value, and + /// matches by exact + /// equality, so an unrecognized combined value falls to its "clear all + /// three" default (NPK), not PK. + /// /// [Theory] [InlineData(0x4, "ID_StatManagement_Header_PKStatus_PK")] [InlineData(0x40, "ID_StatManagement_Header_PKStatus_PKL")] [InlineData(0x2, "ID_StatManagement_Header_PKStatus_NPK")] // plain NPK bit [InlineData(0x0, "ID_StatManagement_Header_PKStatus_NPK")] // Undef — still resolves NPK, not omitted - // Bitwise test (not the prior exact-equality switch): PK combined with - // an unrelated flag (Unprotected, 0x08) still resolves PK — IsPK() is - // true regardless of the other bits. - [InlineData(0x4 | 0x8, "ID_StatManagement_Header_PKStatus_PK")] public void BuildSheet_PkStatus_ResolvesCorrectKeyByStatus(int rawStatus, string expectedKey) { var objects = new ClientObjectTable(); @@ -456,8 +467,8 @@ public sealed class CharacterSheetProviderTests }); var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" }; - obj.Properties.Ints[134u] = rawStatus; objects.AddOrUpdate(obj); + objects.UpdateIntProperty(PlayerGuid, 134u, rawStatus); CharacterSheet sheet = provider.BuildSheet(); @@ -476,8 +487,8 @@ public sealed class CharacterSheetProviderTests var provider = new CharacterSheetProvider(objects, player, playerGuid: () => PlayerGuid); var obj = new ClientObject { ObjectId = PlayerGuid, Name = "Testy" }; - obj.Properties.Ints[134u] = 0x4; // PK objects.AddOrUpdate(obj); + objects.UpdateIntProperty(PlayerGuid, 134u, 0x4); // PK Assert.Null(provider.BuildSheet().PkStatus); } diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 5b0833e1..8640fd93 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -49,7 +49,10 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter); - Assert.Equal("Female Aluvian Adventurer", heritage.LinesProvider()[0].Text); + // CT4 fix round (BLOCKER 2): retail AppendText's the title VERBATIM + // (@0x004f0990) — SampleData's "the Adventurer" keeps its lowercase + // article, unlike the pre-fix-round stripped "Adventurer". + Assert.Equal("Female Aluvian the Adventurer", heritage.LinesProvider()[0].Text); Assert.Equal("Non-Player Killer", pk.LinesProvider()[0].Text); } @@ -76,7 +79,27 @@ public class CharacterStatControllerTests Title = "the Adventurer", }; - Assert.Equal("Female Aluvian Adventurer", CharacterIdentityText.StatHeaderLine(sheet)); + // CT4 fix round (BLOCKER 2): AppendText @0x004f0990 is verbatim — no + // leading-article stripping. + Assert.Equal("Female Aluvian the Adventurer", CharacterIdentityText.StatHeaderLine(sheet)); + } + + /// CT4 fix round (BLOCKER 2): 26 real ACE + /// CharacterTitle entries begin with "The" (capital T, e.g. + /// "The Noob") — retail's verbatim AppendText must not mangle them the + /// way the deleted StripLeadingArticle (which only matched a + /// lowercase "the ") would have left half-stripped anyway. + [Fact] + public void CharacterIdentityText_StatHeaderLine_KeepsCapitalTheTitleUnmangled() + { + var sheet = new CharacterSheet + { + Gender = "Male", + Heritage = "Aluvian", + Title = "The Noob", + }; + + Assert.Equal("Male Aluvian The Noob", CharacterIdentityText.StatHeaderLine(sheet)); } [Theory] @@ -106,6 +129,8 @@ public class CharacterStatControllerTests var hiddenName = new UiText { ElementId = CharacterStatController.NameId }; var visibleHeritage = new UiText { ElementId = CharacterStatController.HeritageId }; var hiddenHeritage = new UiText { ElementId = CharacterStatController.HeritageId }; + var visiblePk = new UiText { ElementId = CharacterStatController.PkStatusId }; + var hiddenPk = new UiText { ElementId = CharacterStatController.PkStatusId }; var visibleLevel = new UiText { ElementId = CharacterStatController.LevelId }; var hiddenLevel = new UiText { ElementId = CharacterStatController.LevelId }; var visibleTotalXp = new UiText { ElementId = CharacterStatController.TotalXpId }; @@ -121,12 +146,14 @@ public class CharacterStatControllerTests attrPage.AddChild(visibleName); attrPage.AddChild(visibleHeritage); + attrPage.AddChild(visiblePk); attrPage.AddChild(visibleLevel); attrPage.AddChild(visibleTotalXpLabel); attrPage.AddChild(visibleTotalXp); attrPage.AddChild(visibleMeter); hiddenPage.AddChild(hiddenName); hiddenPage.AddChild(hiddenHeritage); + hiddenPage.AddChild(hiddenPk); hiddenPage.AddChild(hiddenLevel); hiddenPage.AddChild(hiddenTotalXpLabel); hiddenPage.AddChild(hiddenTotalXp); @@ -138,6 +165,7 @@ public class CharacterStatControllerTests { [CharacterStatController.NameId] = hiddenName, [CharacterStatController.HeritageId] = hiddenHeritage, + [CharacterStatController.PkStatusId] = hiddenPk, [CharacterStatController.LevelId] = hiddenLevel, [CharacterStatController.TotalXpLabelId] = hiddenTotalXpLabel, [CharacterStatController.TotalXpId] = hiddenTotalXp, @@ -148,13 +176,19 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter); Assert.Equal("Studio Player", visibleName.LinesProvider()[0].Text); - Assert.Equal("Female Aluvian Adventurer", visibleHeritage.LinesProvider()[0].Text); + Assert.Equal("Female Aluvian the Adventurer", visibleHeritage.LinesProvider()[0].Text); + // CT4 fix round item 7: this test predates CT4 and did not cover + // PkStatusId — extended here to prove the visible-page-scoping rule + // (Bind reads/writes the ATTRIBUTES-page copy, not the last- + // registered duplicate in _byId) also holds for the PK line. + Assert.Equal("Non-Player Killer", visiblePk.LinesProvider()[0].Text); Assert.Equal("126", visibleLevel.LinesProvider()[0].Text); Assert.Equal("Total Experience (XP):", visibleTotalXpLabel.LinesProvider()[0].Text); Assert.Equal((1_250_000_000L).ToString("N0"), visibleTotalXp.LinesProvider()[0].Text); Assert.Equal((42_000_000L).ToString("N0"), visibleXpNext.LinesProvider()[0].Text); Assert.Empty(hiddenName.LinesProvider()); Assert.Empty(hiddenXpNext.LinesProvider()); + Assert.Empty(hiddenPk.LinesProvider()); } // ── Campaign CT slice CT4: header identity block ─────────────────────── @@ -263,26 +297,24 @@ public class CharacterStatControllerTests Assert.Equal(string.Empty, pk.LinesProvider()[0].Text); } - /// CT4 item 5: the luminance pair (0x100005C5/0x100005C6) - /// toggles Visible per retail's exact gate — UpdateExperience - /// (0x004f0a70): "InqInt(0x19) < 200 || MaximumLuminance == 0" hides - /// both elements; otherwise both show. The elements' own content is left - /// unbound this slice (register row AP-109) — only visibility is - /// asserted here. + /// CT4 item 5, re-bound at the CT4 fix round (BLOCKER 1): the + /// luminance pair (0x100005C5/0x100005C6) shows/hides its TEXT per + /// retail's exact gate — UpdateExperience (0x004f0a70): + /// "InqInt(0x19) < 200 || MaximumLuminance == 0" empties both + /// elements' LinesProvider (retail's ClearAllText, + /// @0x004f0e31/@0x004f0e3c) rather than toggling + /// Visible — see Bind_LuminancePair_ShowsBoundTextWhenGateIsOpen + /// for the actual bound content. [Theory] [InlineData(126, 0L, false)] // below level 200 — hidden regardless of luminance [InlineData(200, 0L, false)] // level gate met, but MaximumLuminance == 0 — hidden [InlineData(200, 1_000_000L, true)] // both conditions met — visible [InlineData(275, 500L, true)] [InlineData(null, 500L, false)] // absent level — treated as "not level 200+" - public void Bind_LuminancePair_TogglesVisibility_PerRetailGate(int? level, long maxLuminance, bool expectedVisible) + public void Bind_LuminancePair_TogglesContentPerRetailGate(int? level, long maxLuminance, bool expectedVisible) { - var label = new UiDatElement( - new ElementInfo { Id = CharacterStatController.LuminanceLabelId, Type = 3 }, - static _ => (0u, 0, 0)); - var value = new UiDatElement( - new ElementInfo { Id = CharacterStatController.LuminanceValueId, Type = 3 }, - static _ => (0u, 0, 0)); + var label = new UiText { ElementId = CharacterStatController.LuminanceLabelId }; + var value = new UiText { ElementId = CharacterStatController.LuminanceValueId }; var layout = Fake( (CharacterStatController.LuminanceLabelId, label), (CharacterStatController.LuminanceValueId, value)); @@ -293,8 +325,43 @@ public class CharacterStatControllerTests MaximumLuminance = maxLuminance, }); - Assert.Equal(expectedVisible, label.Visible); - Assert.Equal(expectedVisible, value.Visible); + Assert.Equal(expectedVisible, label.LinesProvider().Count > 0); + Assert.Equal(expectedVisible, value.LinesProvider().Count > 0); + // ClearAllText leaves layout untouched — Visible is never written by + // this binding at all (retail doesn't touch it either). + Assert.True(label.Visible); + Assert.True(value.Visible); + } + + /// CT4 fix round (BLOCKER 1): caption "Luminance:" (UTF-16 + /// PE-recovered @0x007c3dd4) and value "<available> / <maximum>" + /// (narrow "%s / %s" @0x007c3dcc), each number formatted through the + /// same N0/InvariantCulture helper Total XP uses, in the widget's own + /// authored color. + [Fact] + public void Bind_LuminancePair_ShowsBoundTextWhenGateIsOpen() + { + var authoredColor = new Vector4(0.9f, 0.9f, 0.9f, 1f); + var label = new UiText { ElementId = CharacterStatController.LuminanceLabelId, DefaultColor = authoredColor }; + var value = new UiText { ElementId = CharacterStatController.LuminanceValueId, DefaultColor = authoredColor }; + var layout = Fake( + (CharacterStatController.LuminanceLabelId, label), + (CharacterStatController.LuminanceValueId, value)); + + CharacterStatController.Bind(layout, () => new CharacterSheet + { + Level = 200, + AvailableLuminance = 1_500_000L, + MaximumLuminance = 25_000_000L, + }); + + UiText.Line labelLine = Assert.Single(label.LinesProvider()); + Assert.Equal("Luminance:", labelLine.Text); + Assert.Equal(authoredColor, labelLine.Color); + + UiText.Line valueLine = Assert.Single(value.LinesProvider()); + Assert.Equal("1,500,000 / 25,000,000", valueLine.Text); + Assert.Equal(authoredColor, valueLine.Color); } // ── XP meter fill ──────────────────────────────────────────────────────── From 01e44a701867120d9513ddca846ce384e587fdad Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 00:56:33 +0200 Subject: [PATCH 48/89] docs(CT): CT4 review-closed (fix round e7e32409) Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-24-character-panel-parity-campaign.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index d482a5bf..36fd684e 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -265,9 +265,7 @@ row-template resolver already takes (`RetailUiRuntime.MountCharacter`), and set the list box's authored 24px row height so wheel/line scroll lands row-aligned. -**CT4 — Header identity block. REVIEW-CLOSED 2026-08-25: landed `ed652ed8`, -Opus dual-lens review (2 blockers + 5 should-fix, all applied), fix round -applied 2026-08-25.** Retail composition: name; " +**CT4 — Header identity block. REVIEW-CLOSED 2026-08-25: landed `ed652ed8`, Opus review (2 blockers: luminance strings recovered by the reviewer, verbatim-title append; PK re-sourced to PWD bits), fix round `e7e32409`.** Retail composition: name; " "; PK status line — authored fonts/colors (pure white per probe), live refresh on display-title change and PK status, identical on Attributes AND Skills pages. Level color from the From f532f28c5b6d68d93bc2cb5b37f039aa56572621 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 01:15:32 +0200 Subject: [PATCH 49/89] =?UTF-8?q?feat(ui):=20Campaign=20CT=20slice=20CT5?= =?UTF-8?q?=20=E2=80=94=20attribute/skill=20row=20geometry=20+=20selection?= =?UTF-8?q?=20media?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the hand-built attribute/skill rows in CharacterStatController with the authored shared row template 0x10000248 (LayoutDesc 0x21000045, InfoRegion::InfoRegion @0x004F1450 template index 0 — the same template gmAttributeUI and gmSkillUI both instantiate): - Row geometry replaced with AUTHORED PIXEL VALUES instead of derived fractions: icon flush left 20x20 (was 16x16 at X=4, vertically centered), name column X=25 W=150 fixed (was RowPadX+IconSize+IconGap offset with a width*0.60 fraction), value column X=175 W=100 right-justified (its right edge sits 7px short of the row's 282px right edge — the authored gutter the owner reported). Row width itself now clamps to the authored 282px template width (RowContentWidth) rather than the ListBox's raw 300px container width. Attribute-row height fixed at 20px (was 22px, no dat basis); SkillRowHeight folded into the same RowHeight constant since both row kinds share H=20. - RowHighlightSprite corrected from 0x06001397 to 0x06000F93 — CT1's ground-truth research sealed the verdict that gmAttributeUI:: UpdateSelection @0x0049DEE0 (SetState(6) -> InfoRegion::SetState @0x004F0EE0) swaps the row's Highlight-state media (0x06000F93), a full-row background swap. 0x06001397 belongs to a different mechanism entirely (the spellbook row's UIElement_UIItem::SetSelectedState overlay child) and SpellbookRowStyle.cs is untouched. - UiClickablePanel.UseSelectionBars/SelectionBarHeight retired outright (UiPanel.cs): they existed only to emulate 0x06001397's dark-bars art; the correct retail rendering is the full-panel sprite stretch the base UiPanel.OnDraw already performs, so the override is dead code once the correct sprite is used. No consumer existed outside CharacterStatController. - Per-attribute/per-vital icon DIDs now resolve through the live DBObj::GetDIDByEnum chain (RetailDataIdResolver.Resolve, AP-235's unification seam) when a resolver is supplied — RetailUiRuntime. MountCharacter wires one under the shared DatLock — falling back to the hardcoded AttrRows/VitalRows column otherwise (tests, no dat). gmAttributeUI::PostInit @0x0049DB70 read verbatim: attributes resolve via category 0x10000002 (statId order 1,2,4,3,5,6, matching AttrRows' authored display order exactly); vitals via category 0x10000003. Live-DAT-verified: every hardcoded fallback value already matched the resolved DID byte-exact (new InstalledDat pin AttributeAndVitalIconDids_MatchTheRetailEnumMapperChain). - RetailAppraisalNameResolver.ResolveHeritage's independent re-implementation of the 2/5/13 heritage overrides deleted; it now delegates straight to CharacterIdentityText.HeritageGroupDisplayName (which already bakes in the same overrides) — one owner, byte-identical behavior. AP-235's register row updated to reflect the single-owner fix (the underlying hardcoded-vs-live-DAT mechanism divergence itself stays open — out of CT5's scope). Hand-built-vs-template ruling: rows stay HAND-BUILT rather than converting to UiTemplateListBox instantiation. The hand-built path hits every authored number byte-exact (proven by the CT1 InstalledDat pin AttributeRowTemplate_IconIsFlushLeftTwentyPixels_NameAndValueAreFixedColumns), while conversion would touch ~15 call sites (raise-button affordability, footer State A/B, per-row tooltip, section bucketing, live-refresh, selection-highlight) for a geometry-only slice — smaller-risk path per the task's own judgment-call guidance. Tests: CharacterStatControllerTests' sprite/UseSelectionBars assertions corrected to the authored geometry; new InstalledDat pin for the icon-DID chain. Full hermetic solution suite green (App/Core/Runtime/Headless/ Launcher/Content/etc., 0 failures) and the full InstalledDat lane green (203 App.Tests pins, TowerAscentReplayTests' known Status=KnownFailure case excluded per the acceptance filter). Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 2 +- .../UI/Layout/CharacterStatController.cs | 221 +++++++++++++----- .../UI/Layout/RetailAppraisalNameResolver.cs | 19 +- src/AcDream.App/UI/RetailUiRuntime.cs | 14 +- src/AcDream.App/UI/UiPanel.cs | 62 ++--- .../UI/Layout/CharacterPanelLiveDatTests.cs | 52 +++++ .../UI/Layout/CharacterStatControllerTests.cs | 21 +- 7 files changed, 266 insertions(+), 125 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a1cc6b97..157c7281 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -220,7 +220,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| -| AP-235 | **Filed 2026-08-25 at the Campaign CT4 fix round.** Retail resolves gender display text via `AppraisalSystem::InqGenderDisplayName @0x005b47c0` and heritage via `InqHeritageGroupDisplayName @0x005b4710`, both through the static `EnumMapper::GetString(uint32_t enumValue, uint32_t queryId, PStringBase*) @0x0041ac40` overload — `DBObj::GetDIDByEnum(&did, enumValue, 1)` (master map `0x25000000` → category-1 sub-map `0x25000001` → `ClientEnumToID[0x10000001]`/`[0x10000002]` → EnumMapper DIDs `0x2200000A`/`0x2200000B`) — reading each id's `IdToStringMap` entry live, with heritage ids 2/5/0xd hardcoded to `"Gharu'ndim"`/`"Umbraen"`/`"Olthoi"` in place of the raw internal names `"Gharundim"`/`"Shadowbound"`/`"OlthoiAcid"`. `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# `switch` tables instead — a mechanism divergence (compile-time constant vs. live DAT read), not a content one: `CharacterPanelLiveDatTests.GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain` (filed the same round) walks the live EnumMapper chain and asserts every table entry byte-exact, including the two entries (10 "Penumbraen", 12 "Olthoi") the CT4 review had flagged as unverified guesses — both are correct. | `src/AcDream.App/UI/Layout/CharacterIdentityText.cs` (`GenderDisplayName`, `HeritageGroupDisplayName`); `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs` (`ResolveHeritage` — a SECOND, independent re-implementation of the same 2/5/13 overrides, its own instance of this row's divergence) | `RetailDataIdResolver.Resolve` (`src/AcDream.Content/RetailDataIdResolver.cs`) already ports the generic two-level `GetDIDByEnum` chain (used today for layout/material DIDs); unifying gender/heritage onto it needs only `Resolve(dats, enumValue: 0x10000001u/0x10000002u, enumCategory: 1u)` plus an `EnumMapper.IdToStringMap` read — a live-DAT-only path with no bespoke traversal code to write, which is why the tables stayed hardcoded this round rather than porting live-read on the spot; CT5 is the natural landing slot since it already owns this same DAT-lookup family for the Titles page | A future DAT/game update that renames or reorders a heritage/gender enum entry would silently desync acdream's hardcoded tables from retail's live text with no build-time or runtime signal, and `RetailAppraisalNameResolver.ResolveHeritage`'s independent copy could drift from `CharacterIdentityText`'s even absent any DAT change | `AppraisalSystem::InqGenderDisplayName @ 0x005B47C0`; `InqHeritageGroupDisplayName @ 0x005B4710`; `EnumMapper::GetString @ 0x0041AC40`; `DBObj::GetDIDByEnum @ 0x004153A0` | +| AP-235 | **Filed 2026-08-25 at the Campaign CT4 fix round.** Retail resolves gender display text via `AppraisalSystem::InqGenderDisplayName @0x005b47c0` and heritage via `InqHeritageGroupDisplayName @0x005b4710`, both through the static `EnumMapper::GetString(uint32_t enumValue, uint32_t queryId, PStringBase*) @0x0041ac40` overload — `DBObj::GetDIDByEnum(&did, enumValue, 1)` (master map `0x25000000` → category-1 sub-map `0x25000001` → `ClientEnumToID[0x10000001]`/`[0x10000002]` → EnumMapper DIDs `0x2200000A`/`0x2200000B`) — reading each id's `IdToStringMap` entry live, with heritage ids 2/5/0xd hardcoded to `"Gharu'ndim"`/`"Umbraen"`/`"Olthoi"` in place of the raw internal names `"Gharundim"`/`"Shadowbound"`/`"OlthoiAcid"`. `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# `switch` tables instead — a mechanism divergence (compile-time constant vs. live DAT read), not a content one: `CharacterPanelLiveDatTests.GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain` (filed the same round) walks the live EnumMapper chain and asserts every table entry byte-exact, including the two entries (10 "Penumbraen", 12 "Olthoi") the CT4 review had flagged as unverified guesses — both are correct. | `src/AcDream.App/UI/Layout/CharacterIdentityText.cs` (`GenderDisplayName`, `HeritageGroupDisplayName`); `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs` (`ResolveHeritage` — CT5 fix round 2026-08-25 deleted its independent re-implementation of the same 2/5/13 overrides; it now delegates straight to `CharacterIdentityText.HeritageGroupDisplayName`, so this row's divergence has exactly ONE owner, not two) | `RetailDataIdResolver.Resolve` (`src/AcDream.Content/RetailDataIdResolver.cs`) already ports the generic two-level `GetDIDByEnum` chain (used today for layout/material DIDs); unifying gender/heritage onto it needs only `Resolve(dats, enumValue: 0x10000001u/0x10000002u, enumCategory: 1u)` plus an `EnumMapper.IdToStringMap` read — a live-DAT-only path with no bespoke traversal code to write, which is why the tables stayed hardcoded this round rather than porting live-read on the spot; CT5 is the natural landing slot since it already owns this same DAT-lookup family for the Titles page | A future DAT/game update that renames or reorders a heritage/gender enum entry would silently desync acdream's hardcoded tables from retail's live text with no build-time or runtime signal — the CT5 fix round retired the second-copy drift risk (`ResolveHeritage` now reads the same single table), but the core hardcoded-vs-live-DAT divergence itself remains open | `AppraisalSystem::InqGenderDisplayName @ 0x005B47C0`; `InqHeritageGroupDisplayName @ 0x005B4710`; `EnumMapper::GetString @ 0x0041AC40`; `DBObj::GetDIDByEnum @ 0x004153A0` | | AP-234 | **Filed 2026-08-23 at the #426 solid-face extraction fix.** Cell-wall (EnvCell/CellStruct) geometry approximates retail's "skip untextured subsets inside a cell interior" with the polygon's own `Stippling.NoPos` flag rather than resolving the Surface's own `Type` (`Base1Image`/`Base1ClipMap`) before the per-polygon draw decision — the same NoPos-vs-surface-type conflation #426 fixed for ordinary GfxObj extraction (`PrepareGfxObjMeshData`/`GfxObjMesh.Build`), deliberately LEFT in place here | `src/AcDream.Core/Meshing/CellMesh.cs:45`; `src/AcDream.Content/MeshExtractor.cs`'s `PrepareCellStructMeshData` `hasPos` gate carries the identical rule | Cells are the one retail context that genuinely skips untextured subsets (`DrawEnvCell`), so approximating "untextured" with NoPos is directionally correct for the common case — a solid-colour polygon always carries NoPos since it has no UVs to carry; resolving Surface.Type first would need a per-polygon dat lookup this code doesn't currently perform before the emit/skip decision | A textured polygon whose author left NoPos set (no positive UVs authored despite a real texture) would be wrongly skipped as if untextured, or an untextured polygon whose author left NoPos unset would wrongly draw — either edge case shows as a cell wall gaining or losing a face relative to retail | `RenderDeviceD3D::DrawEnvCell` @0x0059f170 → `D3DPolyRender::DrawMesh(..., arg4=1)`; `RetailUntexturedSurfacePolicy`/`RetailUntexturedSubsetPolicy` (`src/AcDream.Core/Meshing/RetailUntexturedSurfacePolicy.cs`) | | AP-233 | **Filed 2026-08-23 at the Holtburg windmill fix (row owed since the R1-P5 sequencer cutover).** `AnimationSequencer.BuildBlendedFrame` blends each part between `floor(FrameNumber)` and the next frame in the playback direction using the retail slerp (`SlerpRetailClient`). Retail never blends animation frames: `CPartArray::UpdateParts` applies `CSequence::get_curr_animframe` = `get_part_frame(floor(frame_number))`, holding every authored 30 fps frame for its whole interval. Since 2026-08-23 the blend holds the boundary frame at BOTH ends of a node's window — including the cyclic seam — so a cycle's last→first transition is retail's hard cut, not a blend. | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`BuildBlendedFrame`); tests `AnimationSequencerTests.Advance_LinkTailDoesNotBlendIntoLinkFrame0` (#61), `Advance_CyclicSeamHoldsLastFrameInsteadOfBlendingIntoFrame0` (windmill) | The blend only smooths between authored interior frames of one node; at every seam the pose is exactly retail's held frame. Authored cycles that loop by symmetry (the Holtburg windmill's 60-frame quarter turn, `0x0300061B`) or by design read identically at the seam; link tails hold their end pose (#61). The owner chose this over dropping the blend (retail's 30 fps stepping) on 2026-08-23. | Any two adjacent authored frames that are NOT meant to be traversed smoothly (a deliberate authored pop inside a node) would be smoothed where retail pops; none known. A per-frame hitch of one held 33 ms interval at each cycle seam is the price of the cut (1.5° on the windmill). | `CPartArray::UpdateParts @0x005190F0`; `CSequence::get_curr_animframe @0x00524970`; `CSequence::get_curr_frame_number @0x005249D0` | | AP-232 | **Filed 2026-08-22 at Campaign VM slice VM1 (the #226 single-pass re-port; deviation introduced at `05970306`, row owed since then).** Retail's single-pass detail combine produces ONE pixel per subset whose OUTPUT alpha is stage 1's `MODULATE(TEXTURE, CURRENT)` (`D3DPolyRender::SetSurface @0x0059c4d0`, op at `0x0059c549`) — for a delayed-alpha (translucent) subset that product is the framebuffer blend weight. acdream draws the base subset with its own alpha, then a second `mesh_detail` draw weighted by `detail.a * instanceOpacity` under `SRCALPHA + INVSRCALPHA`. For OPAQUE subsets (base alpha 1) the two compose to exactly `lerp(base, detail, detail.a*opacity)` and, with both draws fogged, to retail's fog-after-combine pixel (identity pinned by `RetailDetailTextureContractTests`). For TRANSLUCENT building/EnvCell subsets the destination after the base draw is `mix(behind, foggedBase, baseAlpha)`, not `foggedBase`, so the detail weight differs from retail's single product. | `src/AcDream.App/Rendering/Shaders/mesh_detail.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs` (transparent interleave); `src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs` (transparent interleave) | Opaque subsets are the overwhelming majority of building shells and interior walls and are exact; translucent detail-bearing subsets (ClipMap/alpha/additive/inverse-alpha glass and grates) get a bounded weight difference that never exceeds the detail texture's own alpha (mean 0.132 on the live Dereth category texture). Collapsing to one draw would require the base pipelines to sample the detail texture, i.e. a second `mesh_modern` variant on the retail path. | A translucent building/EnvCell surface with the detail preference on reads visibly different from retail against a bright background. Separate from AP-34 (queue ORDER); this row is about the blend WEIGHT. | `D3DPolyRender::SetSurface @0x0059c4d0` (stage table), `RenderMeshSubset @0x0059ca10`; VM2 cdb note `docs/research/2026-08-22-vm2-retail-detail-path-cdb.md` | diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index 1aa203f3..6e1e0703 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -134,8 +134,14 @@ public static class CharacterStatController // the level element authors its own pale-gold FontColor (+ Outline); // LabelAuthoredColor reads it from the widget instead. - /// Row highlight color — semi-translucent gold, matches retail - /// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent. + /// Row highlight FALLBACK color — used only when + /// spriteResolve is unavailable (tests, headless mode) and the + /// real art cannot be drawn. CT5 fix + /// round: the former comment here claimed this tint "matches retail + /// sprite 0x06001397 visual intent" — that was never accurate (0x06001397 + /// is the spellbook's unrelated overlay sprite, and this solid tint was + /// never tuned to any specific sprite's actual pixels either way); it is + /// a synthetic no-art placeholder, not a retail-faithful color. private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f); // LayoutDesc 0x2100002E, FooterTitle 0x1000024E property 0x1B: // [0]=white, [1]=green, [2]=red, [3]=light blue (#7FFFFF). @@ -144,24 +150,73 @@ public static class CharacterStatController private static readonly Vector4 RetailVitaeBlue = new(127f / 255f, 1f, 1f, 1f); // ── Row layout constants ───────────────────────────────────────────────── - // RowHeight 22px + IconSize 16px: retail spec (2026-06-26) says icons ~icon-height - // and rows tighter. 16px icon fits inside 22px row with 3px vertical padding each side. - // The larger row font (0x40000001, MaxCharHeight=18) is clipped to the 22px height which - // gives a tight-but-readable line. Retail spec (2026-06-26 ref): "rows tighter, text ≈ icon height". - private const float RowHeight = 22f; - private const float IconSize = 16f; - private const float RowPadX = 4f; - private const float IconGap = 6f; + // Campaign CT slice CT5 (2026-08-25): the shared attribute/skill data-row + // template 0x10000248 (LayoutDesc 0x21000045 — InfoRegion::InfoRegion + // @0x004F1450 template index 0, used for BOTH gmAttributeUI and + // gmSkillUI rows) authors W=282 H=20 with icon 0x10000129 FLUSH LEFT + // (X=0 Y=0 W=20 H=20, full row height), name 0x1000012A at FIXED X=25 + // W=150, and value 0x1000012B at FIXED X=175 W=100 right-justified — + // its right edge (275) sits 7px short of the row's own 282px right + // edge, the authored gutter the owner reported (item 2). These are + // AUTHORED PIXEL VALUES, not width-relative fractions or derived + // offsets — the former RowHeight=22/IconSize=16px-at-X=4/ + // nameW=width*0.60 geometry had no dat basis at all. Ground truth + + // the explicit warning against composing a derived "gutter" formula: + // docs/research/2026-08-24-campaign-ct-dat-ground-truth.md §2, pinned + // by CharacterPanelLiveDatTests.AttributeRowTemplate_IconIsFlushLeftTwentyPixels_NameAndValueAreFixedColumns. + private const float RowHeight = 20f; + private const float RowIconX = 0f; + private const float RowIconSize = 20f; + private const float RowNameX = 25f; + private const float RowNameW = 150f; + private const float RowValueX = 175f; + private const float RowValueW = 100f; + + // Section-header caption inset ONLY (AddSkillHeader's 0x10000249..0x1000024C + // captions carry their own authored L5/R5 margins property, distinct from + // the data-row template above) — retained at its pre-CT5 value; CT1 found + // no divergence in the header captions, so this constant is out of CT5's + // scope. + private const float RowPadX = 4f; - private const float SkillRowHeight = 20f; private const float SkillHeaderHeight = 20f; + // Authored row-template width (0x10000248 W=282) — CT5: the row's own + // width now comes from THIS authored constant directly rather than the + // ListBox's raw 300px Width (see RowContentWidth below); do not derive + // a "listWidth minus gutter" formula, per the ground-truth doc's + // explicit warning. private const float SkillContentWidth = 282f; private const uint SkillHeaderSpecializedSprite = 0x06000F90u; private const uint SkillHeaderTrainedSprite = 0x06000F86u; private const uint SkillHeaderUntrainedSprite = 0x06000F98u; private const uint SkillHeaderUnusableSprite = 0x06000F89u; - private const uint RowHighlightSprite = 0x06001397u; + + // CT5 SEALED VERDICT (ground-truth doc §2 "SEALED VERDICT: RowHighlightSprite + // is wrong, not merely flagged"): gmAttributeUI::UpdateSelection + // @0x0049DEE0 calls SetState(selected ? 6 : 1) on the row; InfoRegion:: + // SetState @0x004F0EE0 forwards that to the row instantiated from THIS + // exact template (0x10000248), whose Highlight-state media is + // 0x06000F93 — a full-row background SWAP, not an overlay. The former + // 0x06001397 constant belongs to a DIFFERENT mechanism entirely: the + // spellbook row's separate selected-overlay CHILD element + // (UIElement_UIItem::SetSelectedState @0x004E1240, SpellbookRowStyle.cs) + // — that file and its tests are correct and must NOT be touched. + private const uint RowHighlightSprite = 0x06000F93u; + + // CT5 (AP-235 unification, gmAttributeUI::PostInit @0x0049DB70 verbatim): + // per-attribute icon DIDs resolve via DBObj::GetDIDByEnum(statEnum, + // category 0x10000002); per-vital (Attribute2ndInfoRegion) icon DIDs via + // DBObj::GetDIDByEnum(vitalEnum, category 0x10000003). Both categories + // are consumed through the shared RetailDataIdResolver.Resolve seam + // (the same master-map -> category-map -> value indirection already + // ported for the title chain and RetailKeyNames) rather than a fourth + // ad-hoc hardcoded DID table. Live-DAT-verified (2026-08-25): every + // hardcoded fallback value in AttrRows/VitalRows below already matches + // the resolved DID byte-exact — see + // CharacterPanelLiveDatTests.AttributeAndVitalIconDids_MatchTheRetailEnumMapperChain. + private const uint AttributeIconCategory = 0x10000002u; + private const uint VitalIconCategory = 0x10000003u; // Scrollbar chrome from base layout 0x2100003E, shared with chat/ // inventory — sprite set + retail button seating live in @@ -270,6 +325,17 @@ public static class CharacterStatController /// a (GL tex handle, pixel width, pixel height) triple. Pass null in tests where /// icon rendering is not asserted. /// + /// + /// + /// CT5: ports DBObj::GetDIDByEnum(enumValue, + /// category) (master map -> category map -> value) for the per-attribute/ + /// per-vital row icon DIDs — pass (enumValue, category) => + /// RetailDataIdResolver.Resolve(dats, enumValue, category) under the + /// caller's dat lock. null (tests, or no live dat) falls back to + /// /' hardcoded DID column, + /// which the CT5 InstalledDat pin proves already matches the live-resolved + /// value byte-exact. + /// /// /// /// #431-CA5 gate fix (2026-08-24): the data-changed refresh. Retail's @@ -288,7 +354,8 @@ public static class CharacterStatController UiDatFont? rowDatFont = null, Func? spriteResolve = null, RaiseRequestHandler? onRaiseRequest = null, - Action? onClose = null) + Action? onClose = null, + Func? iconDidResolve = null) { // rowDatFont: larger font for attribute row name/value text (18px vs 16px default). // Falls back to datFont when null (tests, or dat missing). @@ -656,7 +723,7 @@ public static class CharacterStatController skillScrollbar.Visible = false; } currentAttributeRows = BuildAttributeRows(statList, rowDatFont, spriteResolve, data, attrSel, - allRaise1, allRaise10, SetFooterSelected); + allRaise1, allRaise10, SetFooterSelected, iconDidResolve); activeListEntries.AddRange(currentAttributeRows); } else @@ -668,7 +735,7 @@ public static class CharacterStatController Top = 0f, Width = contentW, Height = statList.Height, - LineHeight = (int)SkillRowHeight, + LineHeight = (int)RowHeight, Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, }; statList.AddChild(viewport); @@ -823,6 +890,34 @@ public static class CharacterStatController : SkillContentWidth; } + /// + /// CT5: the row's own rendered width is the AUTHORED row-template width + /// (282px, ), never the ListBox's raw + /// container width (300px, 's + /// own dat rect) — the two numbers do not compose into a "gutter" formula + /// (ground-truth doc §2's explicit warning); a shorter container clamps + /// the row down, but a wider one never stretches it past 282. + /// + private static float RowContentWidth(UiElement list) + => list.Width > 0f ? MathF.Min(list.Width, SkillContentWidth) : SkillContentWidth; + + /// + /// CT5 (AP-235): resolve a row icon DID through the live + /// DBObj::GetDIDByEnum chain when a resolver is available, + /// falling back to the hardcoded / + /// column value otherwise (tests, or a resolve miss). + /// + private static uint ResolveIconDid( + Func? resolver, + uint enumValue, + uint category, + uint fallback) + { + if (resolver is null) return fallback; + uint resolved = resolver(enumValue, category); + return resolved != 0u ? resolved : fallback; + } + // ── 9-row attribute list ───────────────────────────────────────────────── private static List BuildAttributeRows( @@ -833,9 +928,10 @@ public static class CharacterStatController int[] sel, List allRaise1, List allRaise10, - Action setFooterSelected) + Action setFooterSelected, + Func? iconDidResolve) { - float listW = list.Width; + float listW = RowContentWidth(list); float y = 0f; var rows = new List(); @@ -846,7 +942,7 @@ public static class CharacterStatController var row = AddRow(list, datFont, spriteResolve, left: 0f, top: y, width: listW, height: RowHeight, - iconDid: iconDid, + iconDid: ResolveIconDid(iconDidResolve, statId, AttributeIconCategory, iconDid), nameText: rowName, valueProvider: () => { @@ -883,7 +979,7 @@ public static class CharacterStatController var row = AddRow(list, datFont, spriteResolve, left: 0f, top: y, width: listW, height: RowHeight, - iconDid: iconDid, + iconDid: ResolveIconDid(iconDidResolve, maxStatId, VitalIconCategory, iconDid), nameText: rowName, valueProvider: () => { @@ -922,7 +1018,7 @@ public static class CharacterStatController Action setFooterSelected, out List skillRows) { - float listW = list.Width > 0f ? MathF.Min(list.Width, SkillContentWidth) : SkillContentWidth; + float listW = RowContentWidth(list); float y = 0f; var entries = new List(); var bindings = new List(); @@ -951,7 +1047,7 @@ public static class CharacterStatController CharacterSkill LiveSkill() => FindSkill(data(), skill.Id) ?? skill; var row = AddRow(list, datFont, spriteResolve, - left: 0f, top: y, width: listW, height: SkillRowHeight, + left: 0f, top: y, width: listW, height: RowHeight, iconDid: skill.IconDid, nameText: skill.Name, valueProvider: () => LiveSkill().CurrentLevel.ToString(), @@ -967,7 +1063,7 @@ public static class CharacterStatController }; bindings.Add(new SkillRowBinding(row, skill)); entries.Add(row); - y += SkillRowHeight; + y += RowHeight; } } } @@ -1131,10 +1227,10 @@ public static class CharacterStatController Console.WriteLine($"[CharacterStat] Row click: index={clickedIndex} → selected={newSel} ({rowName})"); // Update highlight on all rows. - // Retail uses sprite 0x06001397 (Button state 6 — the dark horizontal bars) - // for the selected row background. When spriteResolve is available, apply the - // sprite; otherwise fall back to the translucent gold tint. - const uint HighlightSprite = 0x06001397u; + // CT5: retail's InfoRegion::SetState swaps the row's whole background + // to the template's Highlight-state media (RowHighlightSprite, + // 0x06000F93 — see its own doc comment) when spriteResolve is + // available; otherwise fall back to the translucent gold tint. for (int i = 0; i < rows.Count; i++) { var row = rows[i]; @@ -1143,7 +1239,7 @@ public static class CharacterStatController if (spriteResolve is not null) { row.BackgroundColor = Vector4.Zero; - row.BackgroundSprite = HighlightSprite; + row.BackgroundSprite = RowHighlightSprite; row.SpriteResolve = spriteResolve; } else @@ -1201,14 +1297,12 @@ public static class CharacterStatController row.BackgroundColor = Vector4.Zero; row.BackgroundSprite = RowHighlightSprite; row.SpriteResolve = id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); }; - row.UseSelectionBars = true; } else { row.BackgroundColor = HighlightBg; row.BackgroundSprite = 0u; row.SpriteResolve = null; - row.UseSelectionBars = false; } } else @@ -1216,7 +1310,6 @@ public static class CharacterStatController row.BackgroundColor = Vector4.Zero; row.BackgroundSprite = 0u; row.SpriteResolve = null; - row.UseSelectionBars = true; } } } @@ -1615,9 +1708,24 @@ public static class CharacterStatController } /// - /// Add a single attribute/vital row to as a - /// containing icon + name + value children. - /// Returns the panel so the caller can wire . + /// Add a single attribute/vital/skill row to as a + /// containing icon + name + value children, + /// laid out at the AUTHORED template 0x10000248 pixel geometry (icon + /// flush left 20x20, name at X=25 W=150, value at X=175 W=100 + /// right-justified — see the row-layout-constants block above). Returns + /// the panel so the caller can wire . + /// + /// CT5 hand-built-vs-template ruling: this row stays HAND-BUILT + /// (not converted to UiTemplateListBox row instantiation). + /// Converting would touch every one of the ~15 call sites this method's + /// row feeds — raise-button affordability, footer State A/B, per-row + /// tooltip, section-header bucketing, live-refresh-on-quality-change, + /// and the selection-highlight swap — for a purely cosmetic slice whose + /// authored numbers this hand-built path can already hit byte-exact + /// (proven by CharacterPanelLiveDatTests.AttributeRowTemplate_...). + /// That is a large-blast-radius rewrite for a geometry-only fix; the + /// smaller-risk path is implementing the authored constants directly + /// here, which this method now does. /// private static UiClickablePanel AddRow( UiElement list, @@ -1628,9 +1736,7 @@ public static class CharacterStatController string nameText, Func valueProvider, Func? valueColorProvider = null, - Vector4? nameColor = null, - uint backgroundSprite = 0u, - bool useSelectionBars = true) + Vector4? nameColor = null) { var row = new UiClickablePanel { @@ -1649,23 +1755,20 @@ public static class CharacterStatController Width = width, Height = height, BackgroundColor = Vector4.Zero, // transparent until selected - BackgroundSprite = spriteResolve is not null ? backgroundSprite : 0u, - SpriteResolve = spriteResolve is not null && backgroundSprite != 0u - ? id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); } - : null, + BackgroundSprite = 0u, + SpriteResolve = null, BorderColor = Vector4.Zero, - UseSelectionBars = useSelectionBars, Anchors = AnchorEdges.Left | AnchorEdges.Top, }; - float iconY = (height - IconSize) * 0.5f; - + // Icon 0x10000129: flush left (X=0), Y=0, 20x20 — full row height, + // no vertical centering math needed since RowIconSize == RowHeight. var iconEl = new UiText { - Left = RowPadX, - Top = iconY, - Width = IconSize, - Height = IconSize, + Left = RowIconX, + Top = 0f, + Width = RowIconSize, + Height = RowIconSize, ClickThrough = true, DatFont = null, BackgroundSprite = spriteResolve is not null ? iconDid : 0u, @@ -1676,17 +1779,15 @@ public static class CharacterStatController Anchors = AnchorEdges.Left | AnchorEdges.Top, }; - float nameX = RowPadX + IconSize + IconGap; - float nameW = width * 0.60f; - float nameY = 0f; - + // Name 0x1000012A: X=25 W=150 — fixed authored pixels, not a + // width-relative fraction. string capturedName = nameText; Vector4 capturedNameColor = nameColor ?? Body; var nameEl = new UiText { - Left = nameX, - Top = nameY, - Width = nameW, + Left = RowNameX, + Top = 0f, + Width = RowNameW, Height = height, DatFont = datFont, ClickThrough = true, @@ -1697,14 +1798,14 @@ public static class CharacterStatController }; nameEl.LinesProvider = () => new[] { new UiText.Line(capturedName, capturedNameColor) }; - float valueW = width - nameX - nameW - RowPadX; - float valueX = nameX + nameW; - + // Value 0x1000012B: X=175 W=100, right-justified — its right edge + // (275) sits 7px short of the row's own 282px right edge, the + // authored gutter the owner reported. var valueEl = new UiText { - Left = valueX, - Top = nameY, - Width = valueW > 0f ? valueW : 40f, + Left = RowValueX, + Top = 0f, + Width = RowValueW, Height = height, DatFont = datFont, ClickThrough = true, diff --git a/src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs b/src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs index 7546525f..7b0ede81 100644 --- a/src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs +++ b/src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs @@ -71,15 +71,18 @@ public sealed class RetailAppraisalNameResolver public string ResolveCreature(int creatureType) => _creatures.Resolve(creatureType); + // Campaign CT slice CT5 (2026-08-25): the three hardcoded overrides this + // method used to re-implement inline (2/5/13 -> "Gharu'ndim"/"Umbraen"/ + // "Olthoi") are dead duplication — CharacterIdentityText.HeritageGroupDisplayName + // already bakes the identical AppraisalSystem::InqHeritageGroupDisplayName + // overrides into its own switch (including id 12, which this method used + // to leave to the fallback branch and get the same "Olthoi" answer + // anyway). One owner for the override table now; behavior is unchanged + // (byte-identical for every heritage id — verified by the existing + // GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain pin, + // which exercises the shared table directly). public string ResolveHeritage(int heritageGroup) - => heritageGroup switch - { - 2 => "Gharu'ndim", - 5 => "Umbraen", - 13 => "Olthoi", - _ => CharacterIdentityText.HeritageGroupDisplayName(heritageGroup) - ?? string.Empty, - }; + => CharacterIdentityText.HeritageGroupDisplayName(heritageGroup) ?? string.Empty; public string ResolveMaterial(int materialType) => materialType > 0 diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 0bfa426a..dc8539a3 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -4038,6 +4038,17 @@ public sealed class RetailUiRuntime : IDisposable } CharacterSheetProvider provider = _bindings.Character.Provider; CharacterSheet currentSheet = provider.BuildSheet(); + // CT5 (AP-235): resolve per-attribute/per-vital row icon DIDs through + // the live DBObj::GetDIDByEnum chain (RetailDataIdResolver.Resolve) + // instead of leaving CharacterStatController's hardcoded fallback as + // the only source. DatCollection is documented not thread-safe, so + // this closure takes the same DatLock every other dat-touching + // delegate below (TitleTemplateResolver/TitleResolver) already uses. + uint IconDidResolve(uint enumValue, uint category) + { + lock (_bindings.Assets.DatLock) + return RetailDataIdResolver.Resolve(_bindings.Assets.Dats, enumValue, category); + } Action refreshRows = CharacterStatController.Bind( layout, () => currentSheet, @@ -4045,7 +4056,8 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Assets.ResolveFont(0x40000001u) ?? _bindings.Assets.DefaultFont, _bindings.Assets.ResolveSprite, (request, completed) => HandleCharacterRaise(provider, request, completed), - () => CloseWindow(WindowNames.Character)); + () => CloseWindow(WindowNames.Character), + IconDidResolve); // #431-CA5 gate fix: rebuild the ROWS on every authoritative sheet // change, not only on clicks — a train's skill record must move the // row to the trained section (and un-ghost the raise controls) the diff --git a/src/AcDream.App/UI/UiPanel.cs b/src/AcDream.App/UI/UiPanel.cs index f346432f..5ad5b063 100644 --- a/src/AcDream.App/UI/UiPanel.cs +++ b/src/AcDream.App/UI/UiPanel.cs @@ -26,8 +26,10 @@ public class UiPanel : UiElement /// Optional dat RenderSurface id for the panel background sprite, drawn /// in place of (or alongside) . 0 = none. - /// When set, the sprite is stretched to fill the panel rect. - /// Used by the attribute-list selected-row highlight (sprite 0x06001397 = Button state 6). + /// When set, the sprite is stretched to fill the panel rect — the same + /// full-swap semantics retail's InfoRegion::SetState uses. Used by + /// the character-panel attribute/skill row's selected-row highlight + /// (sprite 0x06000F93, template 0x10000248's Highlight-state media). public uint BackgroundSprite { get; set; } /// Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height). @@ -191,29 +193,22 @@ public class UiSimpleButton : UiPanel /// parent window. In acdream we wire the equivalent via this action callback instead of /// the retail message bus. /// -/// When is true and -/// is non-zero, draws the sprite as a thin full-width bar at the TOP and BOTTOM edges of -/// the row (not stretched to fill). This matches retail's selection highlight which shows -/// a horizontal dark bar on both the top and bottom edge of the selected attribute row, -/// with NO left/right end-caps. Bar height is pixels -/// (default 3px). +/// Campaign CT slice CT5 (2026-08-25): the selected-row highlight draws through the +/// inherited full-panel +/// stretch — no override here. This class previously had its own "selection bars" draw +/// mode (a thin top/bottom-bar rendering tuned to look like sprite 0x06001397's dark +/// bars); CT1's ground-truth research found that sprite belongs to a DIFFERENT retail +/// mechanism entirely (the spellbook row's overlay child), and the row's actual retail +/// Highlight state (InfoRegion::SetState, media 0x06000F93) is a plain full-row +/// background SWAP — exactly what the inherited already +/// draws. The bars mode was therefore retired rather than reconfigured to a wrong sprite's +/// geometry; no consumer outside CharacterStatController ever set it. /// public class UiClickablePanel : UiPanel { /// Called when the user releases the left mouse button over this panel. public Action? OnClick { get; set; } - /// When true and is non-zero, draws - /// the sprite as a thin horizontal bar at the top AND bottom edges of the panel, - /// NOT as a full-height stretched fill. Matches retail's selected-row highlight - /// (sprite 0x06001397 — 300×32 px — shown as bars, not a block fill). - /// Default false (preserves legacy full-stretch behavior). - public bool UseSelectionBars { get; set; } - - /// Height in pixels of each selection bar (top and bottom). Default 3px. - /// Ignored when is false. - public float SelectionBarHeight { get; set; } = 3f; - /// Settable tooltip, surfaced through the shared /// hover pipeline (same pattern as /// / ). TS-85's @@ -250,33 +245,4 @@ public class UiClickablePanel : UiPanel } return false; } - - protected override void OnDraw(UiRenderContext ctx) - { - if (UseSelectionBars && BackgroundSprite != 0 && SpriteResolve is { } sr) - { - // Draw the selection highlight as a thin bar at the TOP and BOTTOM of the row. - // The sprite (0x06001397) is 300×32 px — we draw it as horizontal strips at - // native height (SelectionBarHeight), stretched to full panel width (UV tile - // horizontally). No left/right end-caps: u0=0, u1=Width/nativeW (UV repeat). - var (tex, tw, th) = sr(BackgroundSprite); - if (tex != 0 && tw > 0 && th > 0) - { - float barH = SelectionBarHeight; - float uTile = tw > 0 ? Width / tw : 1f; - // Top bar: shows the top barH px of the sprite (v = 0 → barH/th). - float vBot = th > 0 ? barH / th : 1f; - ctx.DrawSprite(tex, 0f, 0f, Width, barH, 0f, 0f, uTile, vBot, Vector4.One); - // Bottom bar: shows the bottom barH px of the sprite (v = 1−barH/th → 1). - float vTop2 = th > 0 ? 1f - barH / th : 0f; - ctx.DrawSprite(tex, 0f, Height - barH, Width, barH, 0f, vTop2, uTile, 1f, Vector4.One); - } - // Selection-bar mode draws no border (rows have BorderColor=Zero by design). - } - else - { - // Default UiPanel draw: handles BackgroundSprite, BackgroundColor, AND border. - base.OnDraw(ctx); - } - } } diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs index 032eb077..3eae10da 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.App.UI.Layout; +using AcDream.Content; using DatReaderWriter; namespace AcDream.App.Tests.UI.Layout; @@ -511,4 +512,55 @@ public sealed class CharacterPanelLiveDatTests Assert.Equal("Player Killer Lite", pkLite); Assert.Equal("Non-Player Killer", npk); } + + /// + /// Campaign CT slice CT5 (AP-235): gmAttributeUI::PostInit @0x0049DB70 + /// resolves each row's icon DID via DBObj::GetDIDByEnum(statEnum, category + /// 0x10000002) for the 6 primary attributes (statId order 1,2,4,3,5,6 — + /// matching CharacterStatController.AttrRows' authored display order) + /// and DBObj::GetDIDByEnum(vitalEnum, category 0x10000003) for the 3 + /// vitals' Attribute2ndInfoRegion icon (verified live: the max-vital + /// enum ids 1/3/5 that CharacterStatController.VitalRows already + /// stores resolve to the SAME DID as the current-vital ids 2/4/6 retail's + /// own decomp literally passes, so either works). Both categories route + /// through the shared RetailDataIdResolver.Resolve master-map -> + /// category-map -> value chain — this pin proves every hardcoded fallback + /// DID in AttrRows/VitalRows already matches the live-resolved + /// value byte-exact, the same "regression guard, not a bug pin" pattern as + /// . + /// + [InstalledDatFact] + public void AttributeAndVitalIconDids_MatchTheRetailEnumMapperChain() + { + using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + + const uint attributeIconCategory = 0x10000002u; + (uint statId, uint expectedDid)[] attributes = + { + (1u, 0x060002C8u), // Strength + (2u, 0x060002C4u), // Endurance + (4u, 0x060002C9u), // Coordination + (3u, 0x060002C6u), // Quickness + (5u, 0x060002C5u), // Focus + (6u, 0x060002C7u), // Self + }; + foreach (var (statId, expectedDid) in attributes) + { + uint resolved = RetailDataIdResolver.Resolve(dats, statId, attributeIconCategory); + Assert.Equal(expectedDid, resolved); + } + + const uint vitalIconCategory = 0x10000003u; + (uint maxStatId, uint expectedDid)[] vitals = + { + (1u, 0x06004C3Bu), // Health + (3u, 0x06004C3Cu), // Stamina + (5u, 0x06004C3Du), // Mana + }; + foreach (var (maxStatId, expectedDid) in vitals) + { + uint resolved = RetailDataIdResolver.Resolve(dats, maxStatId, vitalIconCategory); + Assert.Equal(expectedDid, resolved); + } + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 8640fd93..26ce051a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -747,8 +747,12 @@ public class CharacterStatControllerTests [Fact] public void RowClick_WithSpriteResolve_SelectedRowHasHighlightSprite() { - // When spriteResolve is provided, the selected row must use sprite 0x06001397 - // (retail Button-state-6 dark bar) instead of the translucent gold BackgroundColor. + // CT5 SEALED VERDICT (docs/research/2026-08-24-campaign-ct-dat-ground-truth.md + // §2): the row template's Highlight-state media is 0x06000F93 + // (gmAttributeUI::UpdateSelection @0x0049DEE0 SetState(6) -> + // InfoRegion::SetState @0x004F0EE0 on template 0x10000248) — NOT + // 0x06001397, which belongs to the spellbook row's separate + // selected-overlay mechanism (SpellbookRowStyle.cs, untouched here). var list = new UiPanel(); var layout = Fake((CharacterStatController.ListBoxId, list)); @@ -761,7 +765,7 @@ public class CharacterStatControllerTests var rows = list.Children.OfType().ToList(); rows[2].OnClick!(); - Assert.Equal(0x06001397u, rows[2].BackgroundSprite); // selected → sprite + Assert.Equal(0x06000F93u, rows[2].BackgroundSprite); // selected → sprite Assert.Equal(0f, rows[2].BackgroundColor.W); // no tint Assert.Equal(0u, rows[0].BackgroundSprite); // others cleared Assert.Equal(0u, rows[1].BackgroundSprite); @@ -1110,7 +1114,6 @@ public class CharacterStatControllerTests { Assert.Equal(Vector4.Zero, row.BackgroundColor); Assert.Equal(0u, row.BackgroundSprite); - Assert.True(row.UseSelectionBars); }); var rowNames = rows .Select(r => r.Children.OfType().ToList()[1].LinesProvider()[0].Text) @@ -1326,11 +1329,15 @@ public class CharacterStatControllerTests Assert.Equal((11_100_000L).ToString("N0"), l1Value.LinesProvider()[0].Text); Assert.Equal("Unassigned Experience:", l2Label.LinesProvider()[0].Text); Assert.Equal((87_757_321_741L).ToString("N0"), l2Value.LinesProvider()[0].Text); - Assert.Equal(0x06001397u, rows[1].BackgroundSprite); - Assert.True(rows[1].UseSelectionBars); + // CT5: RowHighlightSprite corrected to 0x06000F93 (see the sealed + // verdict on RowClick_WithSpriteResolve_SelectedRowHasHighlightSprite + // above); UseSelectionBars was retired outright (retail's actual + // Highlight state is a full-row background swap, which the + // inherited UiPanel.OnDraw already renders — no bars-only mode + // needed). + Assert.Equal(0x06000F93u, rows[1].BackgroundSprite); Assert.Equal(Vector4.Zero, rows[1].BackgroundColor); Assert.Equal(0u, rows[0].BackgroundSprite); - Assert.True(rows[0].UseSelectionBars); Assert.Equal(Vector4.Zero, rows[0].BackgroundColor); } From 657b84c29719d1639920f96e1100791f11774719 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 01:17:35 +0200 Subject: [PATCH 50/89] =?UTF-8?q?docs(CT):=20CT7=20gate=20script=20draft?= =?UTF-8?q?=20=E2=80=94=20=C2=A71-=C2=A73/=C2=A75=20final,=20=C2=A74=20pen?= =?UTF-8?q?ding=20CT6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../2026-08-25-campaign-ct-test-script.md | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 docs/research/2026-08-25-campaign-ct-test-script.md diff --git a/docs/research/2026-08-25-campaign-ct-test-script.md b/docs/research/2026-08-25-campaign-ct-test-script.md new file mode 100644 index 00000000..1e38407e --- /dev/null +++ b/docs/research/2026-08-25-campaign-ct-test-script.md @@ -0,0 +1,103 @@ +# Campaign CT — connected gate script (CT7, user-driven) + +**Purpose:** live verification of the character-panel parity campaign +(CT1–CT6) against ACE. Launch: the normal connected launch +(`ACDREAM_RETAIL_UI=1`, live ACE at `127.0.0.1:9000`, +`ACDREAM_PAK_PATH=\artifacts\owner-gate\acdream-v5.pak`). +Open the character panel (F9 / toolbar). Retail side-by-side comparison +is the oracle for every visual item. + +Useful ACE console helpers: title grants come from quests/admin — check +`@acecommands` for a title-grant command; `@grantxp` for levels. + +--- + +## 1. Header identity block (CT4) — Attributes AND Skills tabs + +1. **Name line**: the plain character name (rankless characters — the + allegiance rank prefix is a registered deferral, AP-109). +2. **Heritage line**: " " — e.g. + "Female Aluvian War Mage" when a display title is set; just + "Female Aluvian" when none. PASS: matches retail's composition and + spacing exactly; a title beginning with "The" shows unmangled. +3. **PK line**: "Non-Player Killer" (or "Player Killer" / "Player + Killer Lite" on a PK/PKL character) in PURE WHITE. PASS: correct + text + color on BOTH the Attributes and Skills tabs. +4. **Level number**: pale gold with outline (authored color — compare + against retail's level display side-by-side; the owner reported ours + was previously off). +5. **Luminance pair**: on a sub-200 character, NO luminance caption or + value renders. (A level ≥ 200 character with MaximumLuminance shows + "Luminance:" and " / " — verify only if such a + character is available.) +6. **Live update**: set a display title (see §2) — the heritage line + updates the moment the server confirms, with no relog and no panel + re-open. + +## 2. Titles tab (CT3) + +1. Click the **Titles** tab. PASS: the page shows "Current Display + Title:" + the current title (or "Unknown" only when the server's + title id fails to resolve — normally a real title or the authored + empty state), the "All Available Titles:" list, and the + "Set as Display Title" button. +2. **List content**: every earned title, alphabetically sorted, + readable rows. With few titles the scrollbar shows retail's + full-track thumb; with many (if available) the thumb sizes + proportionally and scrolls. +3. **Ghost rule**: with NOTHING selected the button is ghosted. Select + the title that IS the current display title — button stays ghosted. + Select a DIFFERENT title — button un-ghosts. +4. **Set round trip**: click Set as Display Title. PASS: the display + title text updates on the server's confirmation, the selection + CLEARS (row highlight goes dark — retail behavior), the button + re-ghosts, and the §1 heritage line updates live. +5. **Row selection visual**: the selected row highlights with retail's + row highlight art (full-row background swap), not a synthesized bar. + +## 3. Attribute/skill rows (CT5) + +1. **Icon alignment**: row icons sit flush left (20x20 at the row's + left edge), matching retail — the previous inset/smaller icons are + gone. Compare a few rows side-by-side against retail. +2. **Value gutter**: the numbers column ends with a visible margin + before the panel border (the scrollbar band) — retail's 7px gutter. +3. **Row height**: rows are retail-height (slightly tighter than + before); section headers (Trained/Untrained/Unusable) unchanged. +4. **Selection highlight**: clicking a row highlights with the retail + full-row art; the spellbook's selection visuals are UNCHANGED + (regression check — open the spellbook and select a spell). +5. **Raise buttons / tooltips / footer**: regression sweep — raise ×1 + and ×10 still work with correct ghosting, skill tooltips still show + formula + description wrapped correctly, the footer numbers update. + +## 4. Resize + scrollbar (CT6) + +*(Section finalized after CT6 lands — placeholder items below.)* + +1. Resize the character window vertically: it clamps at the authored + minimum; the stat list shows its scrollbar when the shortened + viewport overflows, with retail hover/press states on the bar. +2. Other windows (chat, social) still clamp at their own authored + minimums — regression check. + +## 5. Regression sweep (5 minutes) + +- Attributes/Skills tab switching unaffected; CA5 behaviors intact + (raise round trips, live run-speed update on Quickness). +- Logout/login: titles and display title persist; the header matches + PlayerDescription's values. +- Chat window: the CH-round fixes hold (input rails on focus, "Gen" + caption, button flick, no vibrating text while dragging). + +--- + +## Report back + +Per section: PASS/FAIL plus anything odd. The three answers that matter +most: +1. §2.4 — does the set-title round trip clear the selection and update + the header live? +2. §3.1/§3.2 — do icons and the value gutter now match retail + side-by-side? +3. §1.3 — is the PK line present, white, and correct on both tabs? From 0a37a28e76f9520dbe00aa594c04c6d23eb0940e Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 01:47:25 +0200 Subject: [PATCH 51/89] =?UTF-8?q?fix(ui):=20Campaign=20CT5=20fix=20round?= =?UTF-8?q?=20=E2=80=94=20Normal-state=20row=20media,=20geometry=20test,?= =?UTF-8?q?=20padding,=20doc=20corrections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus review of CT5 (f532f28c) found 0 blockers, 4 SHOULD-FIX, 6 NOTEs; all applied here. SHOULD-FIX 1 (visible retail gap): unselected attribute/skill rows now draw the row template's Normal-state media (0x06004CC2 — pinned by CharacterPanelLiveDatTests.AttributeRowTemplate_...) instead of drawing nothing. Independently decoded against the installed DAT: PFID_A8R8G8B8, 48x48, uniform (0,0,0,175) — a ~69%-opaque black tile the native-size copy-or-tile blit repeats across the row. Wired at all three sites (HandleRowClick, ApplySkillSelectionVisuals, AddRow). Selected rows keep 0x06000F93 (RowHighlightSprite) unchanged. SHOULD-FIX 2: added Bind_AttributeRow_/Bind_VitalRow_/Bind_SkillRow_ geometry tests asserting the authored template pixels against BUILT rows (not DAT pins) — width, height, icon/name/value column positions. The skill-row case reproduces the real production scrollbar (X=281, per CharacterPanelLiveDatTests.StatListBox_AuthorsFiveRowTemplatesInSharedLayout) to prove the documented 281px clamp (scrollbar.Left - list.Left), one pixel short of the attribute/vital rows' 282px ceiling. SHOULD-FIX 3: AddRow's name-column Padding corrected from 1f to 0f — the authored template carries no margin on 0x1000012A; Padding=1f re-created the X=26 glyph-start bug this slice existed to fix. SHOULD-FIX 4: reworded both UiPanel.BackgroundSprite doc comments — the draw is a native-size copy-or-tile blit (UV-repeat), never a stretch. Decoded 0x06000F93 as exactly 282x20 (matches the row natively, draws as a plain copy) vs 0x06004CC2's 48x48 tile. Retail's UIRegion::SetImageByDID (@0x0069F960) decompiles to a pure BlitMode selector switch — param_2==2 -> Blit_3Alpha, ==3 -> Blit_4Alpha, else Blit_Normal — with no width/height touched anywhere in the function, answering CT1's open "draw mode 3" question: it's an alpha-blend selector, not a resize flag. NOTEs: a. AddRow's nameEl now sets OneLine=true so the authored VJustify=Center takes the same single-line vertical-centering path the value column already uses. b. Tempered the "row width is 282" wording in the SkillContentWidth / RowContentWidth doc comments — that's a ceiling attribute/vital rows land on, not a fact true for skill rows (281, via SkillViewportWidth's scrollbar-gutter measurement). c. Reworded the section-header (RowPadX) comment — CT1 verified only the four header SPRITES; the caption label's own authored margins were never checked. Recorded as an open residual, not a cleared divergence. d. AttrRows/VitalRows are now internal (InternalsVisibleTo("AcDream.App.Tests") already covers AcDream.App.Tests); CharacterPanelLiveDatTests iterates them directly instead of a re-typed duplicate array, and now also asserts the vitals 2/4/6 current-enum aliasing claim the doc comment made but never enforced. e. Deleted the stale pre-CT5 0x06001397 narrative in CharacterPanelLiveDatTests; the pin's comment now describes the post-CT5 state (a regression guard, not an open bug). f. Unified ApplySkillSelectionVisuals' selected-branch SpriteResolve wrapper closure with HandleRowClick's direct assignment. Build green; full hermetic solution suite green (Release, Lane!=InstalledDat&...&Status!=KnownFailure filter); InstalledDat lane green (ACDREAM_RUN_INSTALLED_DAT_TESTS=1, Lane=InstalledDat&Status!=KnownFailure). Co-Authored-By: Claude Fable 5 --- .../UI/Layout/CharacterStatController.cs | 130 ++++++++++++++---- src/AcDream.App/UI/UiPanel.cs | 52 +++++-- .../UI/Layout/CharacterPanelLiveDatTests.cs | 69 +++++----- .../UI/Layout/CharacterStatControllerTests.cs | 127 ++++++++++++++++- 4 files changed, 303 insertions(+), 75 deletions(-) diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index 6e1e0703..4fc8a449 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -173,18 +173,35 @@ public static class CharacterStatController private const float RowValueW = 100f; // Section-header caption inset ONLY (AddSkillHeader's 0x10000249..0x1000024C - // captions carry their own authored L5/R5 margins property, distinct from - // the data-row template above) — retained at its pre-CT5 value; CT1 found - // no divergence in the header captions, so this constant is out of CT5's - // scope. + // captions carry their own authored geometry, distinct from the data-row + // template above) — retained at its pre-CT5 value. CT5 fix round + // (NOTE c): CT1 verified only the FOUR SPRITES for these header templates + // (SkillSectionHeaderTemplates_MatchExistingSpriteConstants: Width=280, + // Height=20, sprite match) — the caption LABEL's own authored geometry + // (W=280 with L5/R5 margins per the header template, versus our current + // header width, which comes from RowContentWidth/SkillViewportWidth same + // as the data rows, plus this flat 4px inset) was never pinned or + // cross-checked against that. This is a KNOWN, RECORDED residual gap — + // NOT a cleared divergence — left for a future slice to pin the caption + // child's authored margins the same way CT1 pinned the data-row + // template's icon/name/value columns; behavior is unchanged here. private const float RowPadX = 4f; private const float SkillHeaderHeight = 20f; - // Authored row-template width (0x10000248 W=282) — CT5: the row's own - // width now comes from THIS authored constant directly rather than the - // ListBox's raw 300px Width (see RowContentWidth below); do not derive - // a "listWidth minus gutter" formula, per the ground-truth doc's - // explicit warning. + // Authored row-template CEILING width (0x10000248 W=282) — CT5: the + // row's own width comes from THIS authored constant as an upper bound, + // never the ListBox's raw 300px Width, and never a derived + // "listWidth minus gutter" formula (the ground-truth doc's explicit + // warning). CT5 fix round (NOTE b, tempered wording): this ceiling is + // NOT the row's width in every context. Attribute/vital rows land on it + // exactly (300px ListBox clamped to 282). The SKILL page instead first + // narrows the viewport to SkillViewportWidth's scrollbar-gutter + // measurement (scrollbar.Left − list.Left = 281px in the real + // production layout — CharacterPanelLiveDatTests. + // StatListBox_AuthorsFiveRowTemplatesInSharedLayout pins the scrollbar + // at X=281), and RowContentWidth then clamps skill rows to THAT + // (281 < 282) rather than the raw 282 ceiling — see + // Bind_SkillRow_ClampsToAuthoredScrollbarGutterWidth. private const float SkillContentWidth = 282f; private const uint SkillHeaderSpecializedSprite = 0x06000F90u; @@ -204,6 +221,21 @@ public static class CharacterStatController // — that file and its tests are correct and must NOT be touched. private const uint RowHighlightSprite = 0x06000F93u; + // CT5 fix round (SHOULD-FIX 1, visible retail gap): the row template's + // NORMAL-state media — StateMedia[Normal] on 0x10000248, pinned byte- + // exact by CharacterPanelLiveDatTests. + // AttributeRowTemplate_IconIsFlushLeftTwentyPixels_NameAndValueAreFixedColumns + // (row.StateMedia["Normal"].File == 0x06004CC2). CT5 itself only wired + // the Highlight-state swap above; UNSELECTED rows drew fully + // transparent instead of this authored background. Independently + // decoded against the installed DAT (2026-08-25): PFID_A8R8G8B8, 48x48, + // a single uniform color (0,0,0,175) — i.e. a ~69%-opaque (175/255) + // flat black tile that retail's native-size copy-or-tile blit repeats + // across the row (see UiPanel.BackgroundSprite's doc comment for the + // tiling mechanism) to produce the dark band under every unselected + // attribute/skill row. Selected rows keep RowHighlightSprite above. + private const uint RowNormalSprite = 0x06004CC2u; + // CT5 (AP-235 unification, gmAttributeUI::PostInit @0x0049DB70 verbatim): // per-attribute icon DIDs resolve via DBObj::GetDIDByEnum(statEnum, // category 0x10000002); per-vital (Attribute2ndInfoRegion) icon DIDs via @@ -255,7 +287,13 @@ public static class CharacterStatController private sealed record SkillRowBinding(UiClickablePanel Panel, CharacterSkill Skill); // ── Attribute row descriptors — retail display order per spec §1 ───────── - private static readonly (string name, uint iconDid, uint statId)[] AttrRows = new[] + // CT5 fix round (NOTE d): internal (not private) so + // CharacterPanelLiveDatTests.AttributeAndVitalIconDids_MatchTheRetailEnumMapperChain + // can iterate THESE tuples directly instead of a re-typed duplicate + // literal array — a divergence between the two would previously have + // gone undetected. InternalsVisibleTo("AcDream.App.Tests") already + // covers this assembly (AcDream.App.csproj). + internal static readonly (string name, uint iconDid, uint statId)[] AttrRows = new[] { ("Strength", 0x060002C8u, 1u), ("Endurance", 0x060002C4u, 2u), @@ -265,7 +303,8 @@ public static class CharacterStatController ("Self", 0x060002C7u, 6u), }; - private static readonly (string name, uint iconDid, uint maxStatId)[] VitalRows = new[] + // CT5 fix round (NOTE d): internal for the same reason as AttrRows above. + internal static readonly (string name, uint iconDid, uint maxStatId)[] VitalRows = new[] { ("Health", 0x06004C3Bu, 1u), // max enum 1; current enum 2 ("Stamina", 0x06004C3Cu, 3u), // max enum 3; current enum 4 @@ -891,12 +930,20 @@ public static class CharacterStatController } /// - /// CT5: the row's own rendered width is the AUTHORED row-template width - /// (282px, ), never the ListBox's raw - /// container width (300px, 's - /// own dat rect) — the two numbers do not compose into a "gutter" formula - /// (ground-truth doc §2's explicit warning); a shorter container clamps - /// the row down, but a wider one never stretches it past 282. + /// CT5: the row's own rendered width is 's own + /// width clamped to the AUTHORED row-template CEILING + /// (282px, ) — the two numbers never + /// compose into a derived "gutter" formula (ground-truth doc §2's + /// explicit warning); a shorter clamps the row + /// down, but a wider one never stretches it past 282. + /// CT5 fix round (NOTE b, tempered wording): this does NOT mean + /// every row is 282px. Attribute/vital rows pass the raw ListBox + /// (300px, 's own dat + /// rect) and land on the 282 ceiling exactly. The skill page instead + /// passes the narrower SkillViewportWidth-computed viewport + /// (281px in production — the real scrollbar's authored gutter), so + /// skill rows clamp to 281, one pixel short of the template ceiling. + /// /// private static float RowContentWidth(UiElement list) => list.Width > 0f ? MathF.Min(list.Width, SkillContentWidth) : SkillContentWidth; @@ -1231,6 +1278,10 @@ public static class CharacterStatController // to the template's Highlight-state media (RowHighlightSprite, // 0x06000F93 — see its own doc comment) when spriteResolve is // available; otherwise fall back to the translucent gold tint. + // CT5 fix round (SHOULD-FIX 1): the UNSELECTED branch now draws the + // template's Normal-state media (RowNormalSprite, 0x06004CC2 — see + // its own doc comment) the same way, instead of leaving the row + // fully transparent. for (int i = 0; i < rows.Count; i++) { var row = rows[i]; @@ -1252,8 +1303,8 @@ public static class CharacterStatController else { row.BackgroundColor = Vector4.Zero; - row.BackgroundSprite = 0u; - row.SpriteResolve = null; + row.BackgroundSprite = spriteResolve is not null ? RowNormalSprite : 0u; + row.SpriteResolve = spriteResolve; } } @@ -1296,7 +1347,12 @@ public static class CharacterStatController { row.BackgroundColor = Vector4.Zero; row.BackgroundSprite = RowHighlightSprite; - row.SpriteResolve = id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); }; + // CT5 fix round (NOTE f): unified with HandleRowClick's + // direct assignment — the per-row wrapper closure this + // used to allocate was functionally identical (same + // tuple shape, just differently-named elements, which + // the delegate conversion already accepts without it). + row.SpriteResolve = spriteResolve; } else { @@ -1307,9 +1363,12 @@ public static class CharacterStatController } else { + // CT5 fix round (SHOULD-FIX 1): draw the Normal-state media + // (RowNormalSprite) on unselected rows — see HandleRowClick's + // matching comment for the full citation. row.BackgroundColor = Vector4.Zero; - row.BackgroundSprite = 0u; - row.SpriteResolve = null; + row.BackgroundSprite = spriteResolve is not null ? RowNormalSprite : 0u; + row.SpriteResolve = spriteResolve; } } } @@ -1754,9 +1813,14 @@ public static class CharacterStatController Top = top, Width = width, Height = height, - BackgroundColor = Vector4.Zero, // transparent until selected - BackgroundSprite = 0u, - SpriteResolve = null, + BackgroundColor = Vector4.Zero, + // CT5 fix round (SHOULD-FIX 1): a freshly-built row starts + // unselected, so it gets the template's Normal-state media + // (RowNormalSprite) up front — matching HandleRowClick's/ + // ApplySkillSelectionVisuals' unselected branch exactly, so a + // row never flashes transparent before its first click. + BackgroundSprite = spriteResolve is not null ? RowNormalSprite : 0u, + SpriteResolve = spriteResolve, BorderColor = Vector4.Zero, Anchors = AnchorEdges.Left | AnchorEdges.Top, }; @@ -1793,7 +1857,21 @@ public static class CharacterStatController ClickThrough = true, Centered = false, RightAligned = false, - Padding = 1f, + // CT5 fix round (SHOULD-FIX 3): the authored template's + // 0x1000012A name column carries no margin property at all — + // Padding=1f re-created the X=26 glyph start (RowNameX + 1) this + // very slice existed to remove (see AttributeRowTemplate_...'s + // pin: name.X == 25f, no margin). 0f matches the value column's + // own (already-0) Padding. + Padding = 0f, + // CT5 fix round (NOTE a): both the name and value columns author + // VJustify=Center in the template (0x1000012A/0x1000012B share + // the same default UiText.VerticalJustify). OneLine=true routes + // this element through the same single-line vertical-centering + // draw path the value column below already uses instead of the + // multi-line/scroll path, which does not honor VerticalJustify + // the same way. + OneLine = true, Anchors = AnchorEdges.Left | AnchorEdges.Top, }; nameEl.LinesProvider = () => new[] { new UiText.Line(capturedName, capturedNameColor) }; diff --git a/src/AcDream.App/UI/UiPanel.cs b/src/AcDream.App/UI/UiPanel.cs index 5ad5b063..5b5e81df 100644 --- a/src/AcDream.App/UI/UiPanel.cs +++ b/src/AcDream.App/UI/UiPanel.cs @@ -26,10 +26,35 @@ public class UiPanel : UiElement /// Optional dat RenderSurface id for the panel background sprite, drawn /// in place of (or alongside) . 0 = none. - /// When set, the sprite is stretched to fill the panel rect — the same - /// full-swap semantics retail's InfoRegion::SetState uses. Used by - /// the character-panel attribute/skill row's selected-row highlight - /// (sprite 0x06000F93, template 0x10000248's Highlight-state media). + /// CT5 fix round (SHOULD-FIX 4): drawn at NATIVE SIZE — retail's + /// copy-or-tile blit, never scaled (see UiDatElement.OnDraw's own + /// doc comment, ~lines 273-360, for the full ground truth). The UV + /// rectangle below (Width / tw, Height / th) is deliberately + /// UV-REPEAT, not a fixed 0..1 a stretch would use — GL_REPEAT-wrapped + /// UI textures tile past their native pixel size rather than scaling. + /// Retail's generic sprite blit (Graphic::Draw 0x00693b20 / + /// Graphic::PutImage 0x00693a30) has exactly two behaviors, copy + /// or tile, and can never scale a source image up to fill a larger + /// destination. + /// Used by the character-panel attribute/skill row's Highlight-state + /// swap (sprite 0x06000F93, template 0x10000248's Highlight-state + /// media) and CT5's Normal-state row background (0x06004CC2). The two + /// sprites behave differently under this same tile formula: 0x06000F93 + /// is authored at exactly the row's own 282x20 native size (decoded + /// against the installed DAT, 2026-08-25: PFID_R8G8B8, 282x20), so it + /// draws as a plain COPY with no visible seam; 0x06004CC2 is a 48x48 + /// uniform-color tile (PFID_A8R8G8B8, single color (0,0,0,175)) that + /// visibly TILES across the wider row — both are the same code path, + /// just different source-vs-destination ratios. + /// Retail's own UIRegion::SetImageByDID (@0x0069F960) + /// confirms its third parameter is a BlitMode COLOR-BLEND + /// selector, not a resize flag — the decompiled body switches purely on + /// that value (param_2 == 2 -> Blit_3Alpha, + /// == 3 -> Blit_4Alpha, else Blit_Normal) and + /// never touches width/height at all. This answers CT1's open "icon + /// draw mode 3" question (ground-truth doc §2): mode 3 selects + /// Blit_4Alpha, an alpha-blend variant, not a resize. + /// public uint BackgroundSprite { get; set; } /// Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height). @@ -195,14 +220,17 @@ public class UiSimpleButton : UiPanel /// /// Campaign CT slice CT5 (2026-08-25): the selected-row highlight draws through the /// inherited full-panel -/// stretch — no override here. This class previously had its own "selection bars" draw -/// mode (a thin top/bottom-bar rendering tuned to look like sprite 0x06001397's dark -/// bars); CT1's ground-truth research found that sprite belongs to a DIFFERENT retail -/// mechanism entirely (the spellbook row's overlay child), and the row's actual retail -/// Highlight state (InfoRegion::SetState, media 0x06000F93) is a plain full-row -/// background SWAP — exactly what the inherited already -/// draws. The bars mode was therefore retired rather than reconfigured to a wrong sprite's -/// geometry; no consumer outside CharacterStatController ever set it. +/// draw (native-size copy-or-tile — see that property's own doc comment for the CT5 fix +/// round's correction of the mechanism; NOT a stretch) — no override here. This class +/// previously had its own "selection bars" draw mode (a thin top/bottom-bar rendering +/// tuned to look like sprite 0x06001397's dark bars); CT1's ground-truth research found +/// that sprite belongs to a DIFFERENT retail mechanism entirely (the spellbook row's +/// overlay child), and the row's actual retail Highlight state (InfoRegion::SetState, +/// media 0x06000F93) is a plain full-row background SWAP — exactly what the inherited +/// already draws (0x06000F93 is authored at exactly the row's +/// own 282x20 native size, so the swap needs no scaling to look right). The bars mode was +/// therefore retired rather than reconfigured to a wrong sprite's geometry; no consumer +/// outside CharacterStatController ever set it. /// public class UiClickablePanel : UiPanel { diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs index 3eae10da..5935e61c 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs @@ -208,14 +208,14 @@ public sealed class CharacterPanelLiveDatTests Assert.Equal(282f, row!.Width); Assert.Equal(20f, row.Height); Assert.Equal(0x06004CC2u, row.StateMedia["Normal"].File); - // Row-template Highlight sprite (0x06000F93) — CharacterStatController's - // current RowHighlightSprite private constant is 0x06001397. CT1's - // fix round upgraded this from a flagged divergence to a SEALED - // VERDICT (gmAttributeUI::UpdateSelection SetState(6) -> - // InfoRegion::SetState on this exact template); 0x06001397 belongs - // to the spellbook row's separate selected-overlay mechanism. CT5 - // fixes RowHighlightSprite for the stat rows only; see the research - // doc's "Corrections to the plan" §3. + // Row-template Highlight sprite (0x06000F93) — post-CT5 state: + // CharacterStatController.RowHighlightSprite already matches this + // (CT5 corrected it from the former 0x06001397, which belongs to + // the spellbook row's unrelated separate selected-overlay + // mechanism, per the SEALED VERDICT: gmAttributeUI::UpdateSelection + // SetState(6) -> InfoRegion::SetState on this exact template). This + // pin remains a regression guard against that fixed divergence + // recurring, not a still-open bug. Assert.Equal(0x06000F93u, row.StateMedia["Highlight"].File); ElementInfo icon = Assert.Single(row.Children, c => c.Id == 0x10000129u); @@ -522,11 +522,14 @@ public sealed class CharacterPanelLiveDatTests /// vitals' Attribute2ndInfoRegion icon (verified live: the max-vital /// enum ids 1/3/5 that CharacterStatController.VitalRows already /// stores resolve to the SAME DID as the current-vital ids 2/4/6 retail's - /// own decomp literally passes, so either works). Both categories route - /// through the shared RetailDataIdResolver.Resolve master-map -> - /// category-map -> value chain — this pin proves every hardcoded fallback - /// DID in AttrRows/VitalRows already matches the live-resolved - /// value byte-exact, the same "regression guard, not a bug pin" pattern as + /// own decomp literally passes, so either works — CT5 fix round (NOTE d) + /// added the 2/4/6 assertions below that actually ENFORCE this claim + /// instead of only stating it). Both categories route through the + /// shared RetailDataIdResolver.Resolve master-map -> category-map + /// -> value chain — this pin proves every hardcoded fallback DID in + /// AttrRows/VitalRows (iterated directly, not re-typed) + /// already matches the live-resolved value byte-exact, the same + /// "regression guard, not a bug pin" pattern as /// . /// [InstalledDatFact] @@ -535,32 +538,34 @@ public sealed class CharacterPanelLiveDatTests using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); const uint attributeIconCategory = 0x10000002u; - (uint statId, uint expectedDid)[] attributes = - { - (1u, 0x060002C8u), // Strength - (2u, 0x060002C4u), // Endurance - (4u, 0x060002C9u), // Coordination - (3u, 0x060002C6u), // Quickness - (5u, 0x060002C5u), // Focus - (6u, 0x060002C7u), // Self - }; - foreach (var (statId, expectedDid) in attributes) + // CT5 fix round (NOTE d): AttrRows/VitalRows are now internal, so + // this pin iterates THEM directly instead of a re-typed duplicate + // literal array — a divergence between the two would previously + // have gone undetected by this test. + foreach (var (_, iconDid, statId) in CharacterStatController.AttrRows) { uint resolved = RetailDataIdResolver.Resolve(dats, statId, attributeIconCategory); - Assert.Equal(expectedDid, resolved); + Assert.Equal(iconDid, resolved); } const uint vitalIconCategory = 0x10000003u; - (uint maxStatId, uint expectedDid)[] vitals = - { - (1u, 0x06004C3Bu), // Health - (3u, 0x06004C3Cu), // Stamina - (5u, 0x06004C3Du), // Mana - }; - foreach (var (maxStatId, expectedDid) in vitals) + foreach (var (_, iconDid, maxStatId) in CharacterStatController.VitalRows) { uint resolved = RetailDataIdResolver.Resolve(dats, maxStatId, vitalIconCategory); - Assert.Equal(expectedDid, resolved); + Assert.Equal(iconDid, resolved); + } + + // CT5 fix round (NOTE d): enforce the "aliasing" claim this pin's + // own doc comment made but never actually checked — the + // CURRENT-vital enum ids (2/4/6, i.e. maxStatId+1) that retail's own + // gmAttributeUI::PostInit decomp literally passes must resolve to + // the SAME DID as the MAX-vital ids (1/3/5) VitalRows actually + // stores, live-verified against the installed DAT. + foreach (var (_, iconDid, maxStatId) in CharacterStatController.VitalRows) + { + uint currentStatId = maxStatId + 1u; + uint resolved = RetailDataIdResolver.Resolve(dats, currentStatId, vitalIconCategory); + Assert.Equal(iconDid, resolved); } } } diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 26ce051a..10809fb5 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -516,6 +516,115 @@ public class CharacterStatControllerTests } } + // ── CT5 fix round SHOULD-FIX 2: production BUILT-row geometry ──────────── + // Load-bearing evidence for AddRow's own "hand-built-vs-template ruling" + // doc comment: these tests assert the authored template 0x10000248 pixel + // geometry against Bind's ACTUAL BUILT rows (not the DAT pins + // CharacterPanelLiveDatTests.AttributeRowTemplate_... already covers — + // those pin the template itself, these pin what the controller DOES + // with it). + + [Fact] + public void Bind_AttributeRow_MatchesAuthoredTemplateGeometry() + { + var list = new UiPanel(); + var layout = Fake((CharacterStatController.ListBoxId, list)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter); + + var row = list.Children.OfType().First(); + AssertRowGeometry(row, expectedWidth: 282f); + } + + /// Vital rows (Health/Stamina/Mana) are appended after the 6 + /// attribute rows by BuildAttributeRows — index 6 is the first + /// vital row. + [Fact] + public void Bind_VitalRow_MatchesAuthoredTemplateGeometry() + { + var list = new UiPanel(); + var layout = Fake((CharacterStatController.ListBoxId, list)); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter); + + var row = list.Children.OfType().ToList()[6]; + AssertRowGeometry(row, expectedWidth: 282f); + } + + /// + /// Unlike the attribute/vital test above (a plain + /// list with no dat Width, which falls back to the raw 282px template + /// ceiling — see RowContentWidth's doc comment), this test + /// reproduces the REAL production geometry: the ListBox's authored + /// scrollbar (0x1000023E) sits at X=281 + /// (), + /// so SkillViewportWidth measures the viewport as + /// scrollbar.Left − list.Left = 281 and skill rows clamp one + /// pixel narrower than the attribute/vital rows' 282px ceiling — the + /// documented "282px row template overlaps the 281px scrollbar band by + /// 1px" fact from the ground-truth doc, asserted against the actual + /// mechanism instead of a synthetic width. + /// + [Fact] + public void Bind_SkillRow_ClampsToAuthoredScrollbarGutterWidth() + { + var root = new UiPanel { Width = 300, Height = 600 }; + var page = new UiPanel { Width = 300, Height = 600 }; + var name = new UiText(); + var list = MakeDatElement(CharacterStatController.ListBoxId, top: 112, width: 300, height: 398); + var scrollbarShell = MakeDatElement(CharacterStatController.ListScrollbarId, top: 112, width: 16, height: 398); + scrollbarShell.Left = 281; + + page.AddChild(name); + page.AddChild(list); + page.AddChild(scrollbarShell); + root.AddChild(page); + var skillsTab = MakeTab(CharacterStatController.TabSkillsId, left: 92f); + root.AddChild(skillsTab); + + var layout = new ImportedLayout(root, new Dictionary + { + [CharacterStatController.NameId] = name, + [CharacterStatController.ListBoxId] = list, + [CharacterStatController.ListScrollbarId] = scrollbarShell, + [CharacterStatController.TabSkillsId] = skillsTab, + }); + + CharacterStatController.Bind(layout, SampleData.SampleCharacter, + spriteResolve: id => (id, 16, 16)); + + ClickTab(layout, left: 92f); + + var row = SkillRows(list).First(); + AssertRowGeometry(row, expectedWidth: 281f); + } + + private static void AssertRowGeometry(UiPanel row, float expectedWidth) + { + Assert.Equal(expectedWidth, row.Width); + Assert.Equal(20f, row.Height); + + var texts = row.Children.OfType().ToList(); + Assert.True(texts.Count >= 3, "row must have icon + name + value children"); + UiText icon = texts[0]; + UiText name = texts[1]; + UiText value = texts[2]; + + Assert.Equal(0f, icon.Left); + Assert.Equal(20f, icon.Width); + + Assert.Equal(25f, name.Left); + Assert.Equal(150f, name.Width); + // CT5 fix round (SHOULD-FIX 3): the authored template's name column + // carries no margin — Padding=0 (the former Padding=1f re-created + // the X=26 glyph start this slice existed to remove). + Assert.Equal(0f, name.Padding); + + Assert.Equal(175f, value.Left); + Assert.Equal(100f, value.Width); + Assert.True(value.RightAligned); + } + // ── Footer State A ──────────────────────────────────────────────────────── [Fact] @@ -767,8 +876,10 @@ public class CharacterStatControllerTests Assert.Equal(0x06000F93u, rows[2].BackgroundSprite); // selected → sprite Assert.Equal(0f, rows[2].BackgroundColor.W); // no tint - Assert.Equal(0u, rows[0].BackgroundSprite); // others cleared - Assert.Equal(0u, rows[1].BackgroundSprite); + // CT5 fix round (SHOULD-FIX 1): unselected rows draw the row + // template's Normal-state media (0x06004CC2) instead of nothing. + Assert.Equal(0x06004CC2u, rows[0].BackgroundSprite); // others: Normal-state media + Assert.Equal(0x06004CC2u, rows[1].BackgroundSprite); } [Fact] @@ -784,7 +895,9 @@ public class CharacterStatControllerTests rows[2].OnClick!(); // select rows[2].OnClick!(); // deselect - Assert.Equal(0u, rows[2].BackgroundSprite); + // CT5 fix round (SHOULD-FIX 1): deselecting returns to the + // unselected Normal-state media (0x06004CC2), not zero. + Assert.Equal(0x06004CC2u, rows[2].BackgroundSprite); } // ── Pass 2: Raise button affordability ─────────────────────────────────── @@ -1113,7 +1226,9 @@ public class CharacterStatControllerTests Assert.All(rows, row => { Assert.Equal(Vector4.Zero, row.BackgroundColor); - Assert.Equal(0u, row.BackgroundSprite); + // CT5 fix round (SHOULD-FIX 1): fresh unselected rows draw the + // row template's Normal-state media (0x06004CC2) up front. + Assert.Equal(0x06004CC2u, row.BackgroundSprite); }); var rowNames = rows .Select(r => r.Children.OfType().ToList()[1].LinesProvider()[0].Text) @@ -1337,7 +1452,9 @@ public class CharacterStatControllerTests // needed). Assert.Equal(0x06000F93u, rows[1].BackgroundSprite); Assert.Equal(Vector4.Zero, rows[1].BackgroundColor); - Assert.Equal(0u, rows[0].BackgroundSprite); + // CT5 fix round (SHOULD-FIX 1): the unselected row draws the row + // template's Normal-state media (0x06004CC2), not zero. + Assert.Equal(0x06004CC2u, rows[0].BackgroundSprite); Assert.Equal(Vector4.Zero, rows[0].BackgroundColor); } From 4cbbdaf4bfb1cc012aa5cfdb9ca962bc8a2ab376 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 01:48:08 +0200 Subject: [PATCH 52/89] docs(CT): CT5 review-closed (fix round 0a37a28e) Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-24-character-panel-parity-campaign.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index 36fd684e..2286ab22 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -376,7 +376,7 @@ too, for CT5. `CharacterSheetProvider.BuildSheet`'s level read switched from a `GetInt` + `Ints.ContainsKey` double dictionary lookup to one `TryGetValue`. -**CT5 — Row alignment + value gutter.** Reconcile our hand-built +**CT5 — Row alignment + value gutter. REVIEW-CLOSED 2026-08-25: landed `f532f28c`, Opus review (0 blockers, 4 should-fix incl. the authored Normal-state row band 0x06004CC2), fix round `0a37a28e`.** Reconcile our hand-built attribute/skill rows with the authored row templates from CT1: icon placement, name/value columns, the authored right margin that reserves the scrollbar gutter. From ec50455a63f488982a8ba94ded946d5f0afb8806 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 02:21:56 +0200 Subject: [PATCH 53/89] feat(CT6): character window Y-resize clamped at retail's authored host minimum + shrink-and-scroll list contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CT6 (Campaign CT slice 6): the resize clamp source is the SHARED gmPanelUI host (0x100005FE in LayoutDesc 0x2100006E), not 0x2100002E's own root and not the Character/Skills slot 0x1000018E either — live probe confirmed the host authors MinWidth=MaxWidth=310 (fixed — no horizontal Resizebar authored), MinHeight=372, MaxHeight=1000, and that the bottom Resizebar (0x10000660) and top Dragbar (0x1000065C) are direct children of the host, not the content parent. Decomp chain: UIElement_Resizebar::StartMouseResizing @0x0046B7E0 calls UIElement::StartResizing(this->GetParent(), ...), stashing drag state on that parent; UIElement::MouseResizeElement @0x00461130 then reads GetAttribute_Int(this, 0x3C..0x3F) off that same element every mouse-move. RetailUiRuntime.MountCharacter now imports the host element and passes it as RetailWindowFrame.Options.DatConstraintSource, matching the existing MountSideVitals pattern. CharacterStatController.RebuildActiveList now wraps BOTH the Attributes and Skills tabs' rows in the same UiScrollablePanel viewport (previously only Skills got one; Attributes rows had no clipping/scrolling and the shared scrollbar was force-hidden — owner report item 2). The shared scrollbar is now always bound + visible; UiScrollbar's own IsPresentationVisible/IsModelDisabled already draw the correct full-track "disabled" thumb when content fits. This surfaced and fixed a real #372/#412-class anchor-baseline bug: the viewport's Left|Top|Bottom anchor was capturing its baseline margins lazily on its own first ApplyAnchor call, which happens AFTER the ListBox has already grown from its raw DAT height to its mounted height, permanently capping the viewport short on every later resize. Fixed with an eager CaptureCurrentAnchorBaseline() call, mirroring UiTemplateListBox .Viewport's own lazy getter. CharacterTitlesController.Bind gained the same defensive Anchors = Left|Top|Bottom fallback for the Titles ListBox that CharacterStatController already had (a no-op on the real DAT — both the Titles page and its ListBox already carry a real authored LayoutPolicy that stretches correctly). Standardization audit: UiElement.MinWidth/MinHeight/MaxWidth/MaxHeight, set once at RetailWindowFrame.Mount, are the ONLY clamp fields — read identically by interactive drag, RetailWindowManager.ResizeTo, and RetailWindowLayoutPersistence's restore clamp. No gaps found; no register row (every number is a live-probed authored DAT value or a structural correctness fix, nothing inferred). Tests: CharacterStatControllerTests .CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar, CharacterTitlesControllerTests .TitlesList_ReflowsWithWindowResize_AndScrollbarOverflowFlips, RetailWindowFrameTests .NineSlice_ChatShapedConstraints_ClampProgrammaticResizeAtAuthoredBounds (shared-mechanism regression pin), CharacterPanelLiveDatTests .PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract (InstalledDat pin). Existing attribute-row tests updated from list.Children to Descendants(list) for the new nested-viewport shape (the pattern skill rows already needed). Co-Authored-By: Claude Fable 5 --- ...6-08-24-character-panel-parity-campaign.md | 69 ++++- ...2026-08-24-campaign-ct-dat-ground-truth.md | 124 +++++++++ .../2026-08-25-campaign-ct-test-script.md | 52 +++- .../UI/Layout/CharacterStatController.cs | 91 ++++--- .../UI/Layout/CharacterTitlesController.cs | 12 + src/AcDream.App/UI/RetailUiRuntime.cs | 19 ++ .../UI/Layout/CharacterPanelLiveDatTests.cs | 57 +++++ .../UI/Layout/CharacterStatControllerTests.cs | 242 +++++++++++++++--- .../Layout/CharacterTitlesControllerTests.cs | 95 +++++++ .../UI/Layout/RetailWindowFrameTests.cs | 54 ++++ 10 files changed, 741 insertions(+), 74 deletions(-) diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index 2286ab22..1e6878e6 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -399,8 +399,75 @@ enforcement verified as the STANDARD path for every registered window (one shared mechanism in `RetailWindowFrame`/`RetailWindowManager`, no per-window special cases). +**CT6 landing notes (2026-08-25, implementation).** Live probe (dumped ++ deleted, pattern preserved by the new +`CharacterPanelLiveDatTests.PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract` +pin) confirmed the research lead's hypothesis exactly: the shared +`gmPanelUI` host `0x100005FE` (LayoutDesc `0x2100006E`) authors +MinWidth=MaxWidth=310 (fixed — no horizontal Resizebar), MinHeight=372, +MaxHeight=1000; its bottom Resizebar (`0x10000660`) and top Dragbar +(`0x1000065C`) are DIRECT CHILDREN of the host, not the content parent +— matching `UIElement_Resizebar::StartMouseResizing @0x0046B7E0`'s +`GetParent()` call and `UIElement::MouseResizeElement @0x00461130`'s +`GetAttribute_Int(this, 0x3C..0x3F)` reads off that same parent. The +Character/Skills slot `0x1000018E` itself authors no constraints of its +own (confirmed, same pin). `RetailUiRuntime.MountCharacter` now imports +that host element and passes it as `DatConstraintSource`; the mounted +outer frame clamps at MinWidth=MaxWidth≈320, MinHeight≈382, +MaxHeight≈1010 after the NineSlice chrome inset. Full derivation + +decomp anchors: `docs/research/2026-08-24-campaign-ct-dat-ground-truth.md` +§CT6. +`CharacterStatController.RebuildActiveList` now wraps BOTH the +Attributes and Skills tabs' rows in the same `UiScrollablePanel` +viewport (previously only Skills got one; Attributes rows were added +directly to the ListBox with no clipping/scrolling and the shared +scrollbar was force-hidden — the owner's item 2). The shared scrollbar +is now always bound + visible; `UiScrollbar`'s own +`IsPresentationVisible`/`IsModelDisabled` already draw the correct +full-track "disabled" thumb when content fits (`HideWhenDisabled` +defaults false), so no per-tab visibility toggle is needed any more. +This surfaced and fixed a real, previously-unexercised `#372`/ +`#412`-class anchor-baseline bug: the viewport's `Left|Top|Bottom` +anchor was capturing its baseline margins lazily on its OWN first +`ApplyAnchor` call, which happens AFTER the ListBox has already grown +from its raw DAT height (160px) to its mounted height — measuring a +bogus non-zero margin that permanently capped the viewport short on +every later resize. Fixed with an eager +`viewport.CaptureCurrentAnchorBaseline()` call right after +`AddChild`, mirroring the identical fix already shipped in +`UiTemplateListBox.Viewport`'s own lazy getter. `CharacterTitlesController.Bind` +gained the same defensive `Anchors = Left|Top|Bottom` fallback for the +Titles ListBox (`0x10000532`) that `CharacterStatController` already +had for its own list — live-verified as a no-op on the real DAT (both +the Titles page container and its ListBox already carry a real authored +`LayoutPolicy` that stretches correctly on its own), but matching the +established pattern for synthetic/test layouts. +STANDARDIZATION AUDIT (no gaps found, no follow-up filed): `UiElement +.MinWidth/MinHeight/MaxWidth/MaxHeight`, set once at +`RetailWindowFrame.Mount` from `Options.DatConstraintSource`/explicit +overrides, are the ONLY clamp fields — read identically by the +interactive drag path (`UiRoot`'s resize handling), the programmatic +path (`RetailWindowManager.ResizeTo`, which both `RetailPanelUiController`'s +main-panel geometry sync and this slice's tests exercise), and the +persisted-geometry restore clamp (`RetailWindowLayoutPersistence.Apply`). +`RetailWindowFrame.Mount` remains the single production mount path (no +window bypasses it). New regression pin: +`RetailWindowFrameTests.NineSlice_ChatShapedConstraints_ClampProgrammaticResizeAtAuthoredBounds` +proves the same mechanism still clamps chat-shaped constraints after +Character was wired onto it. Tests: `CharacterStatControllerTests +.CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar` +(window-level: clamp at authored min/max, list shrink, scrollbar +overflow flip, footer stays bottom-docked, grow-back restore) and +`CharacterTitlesControllerTests.TitlesList_ReflowsWithWindowResize_AndScrollbarOverflowFlips` +(same contract for the Titles list) plus the pre-existing 126+22-test +suites, all updated where the new nested-viewport DOM shape required it +(`Descendants(list)` instead of `list.Children` — the shape Skills rows +already needed). No register row: every number is either a live-probed +authored DAT value or a structural anchor-capture-correctness fix, +nothing inferred. + **CT7 — Connected gate.** Test script -(`docs/research/2026-08-24-campaign-ct-test-script.md`), owner drive: +(`docs/research/2026-08-25-campaign-ct-test-script.md`), owner drive: titles round trip against ACE (earn/set/display), header lines vs retail side-by-side, resize behavior, row alignment screenshots. diff --git a/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md b/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md index ddb933ab..656e03af 100644 --- a/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md +++ b/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md @@ -563,3 +563,127 @@ already does steps 3–4 for other consumers. `RetailKeyNames` cross-validation table (§5). The title chain's final two DIDs and end-to-end string resolution ARE pinned (`TitleStringTable_ResolvesWarMageEndToEnd`). + +## §CT6 — the shared-host resize clamp (2026-08-25 live probe) + +**Verdict: the resize clamp source is the shared `gmPanelUI` host +(`0x100005FE` in LayoutDesc `0x2100006E`), not `0x2100002E`'s own root +and not the Character/Skills slot `0x1000018E` either.** Probed with a +temporary test dumping layout `0x2100006E` via both the whole-layout +walk and the targeted single-root overload (deleted before commit; the +pattern is preserved by the committed pin +`CharacterPanelLiveDatTests.PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract`). + +### Host element `0x100005FE` + +``` +Type=0x1000002F (gmPanelUI) X=0 Y=0 W=310 H=372 +MinWidth=310 MaxWidth=310 (fixed — no horizontal Resizebar authored) +MinHeight=372 MaxHeight=1000 +17 children, including (all DIRECT children of the host, siblings of +the content parent, not nested under it): + 0x10000180 content parent Type=3 X=5 Y=5 W=300 H=362 + 0x1000065C top-center Dragbar Type=2 X=5 Y=0 W=300 H=5 + 0x10000660 bottom-center Resizebar Type=9 X=5 Y=367 W=300 H=5 + (+ 14 border/corner chrome pieces, 0x10000653-0x10000662) +``` + +`MinHeight == 372 == the host's own authored default height`: retail's +Character/Skills window can only be resized TALLER (up to 1000px), never +shorter than its own authored default — this IS the "resizable in Y down +to an authored minimum" the owner reported; 372 is that floor, not an +arbitrary smaller number. + +The Character/Skills slot `0x1000018E` (the SAME structural role CT1 +probed for `0x2100002E`'s standalone root `0x10000227`, but reached +through the shared host this time) itself authors **no** MinWidth/ +MinHeight/MaxWidth/MaxHeight — confirming the clamp is exclusively the +HOST's, not layered again on the slot: + +``` +0x1000018E Type=8 (TabControl) X=0 Y=0 W=300 H=362 + Min=(null,null) Max=(null,null) + children: 0x10000228/29 (tab buttons), 0x1000022A (close button), + 0x10000538 (Titles tab), 0x10000539 (Titles page), + 0x1000022B/2C (Attributes/Skills pages) — same ids `0x2100002E` + imports, reached here via `0x1000018E`'s BaseElement inheritance + from `0x10000227` (same mechanism CT1 §3 documents for the Titles + row template's `0x1000052D`/`0x10000536` pair). +``` + +### Decomp chain confirming which element the drag clamp applies to + +- `UIElement_Resizebar::StartMouseResizing @0x0046B7E0`: `eax_1 = + this->vtable->GetParent()` then `UIElement::StartResizing(eax_1, + border, x, y)` — the drag state (`m_DragStartWidth/Height`, + `m_currentBorder`) is stashed on the RESIZEBAR'S PARENT, confirmed + live to be the host `0x100005FE` (§ above), not the content parent. +- `UIElement::StartResizing @0x0045fca0`: pure state setup + (`m_DragStartX/Y/Width/Height`, `m_currentBorder`) on `this` — no + clamp read here. +- `UIElement::MouseResizeElement @0x00461130`: the actual per-mouse-move + resize application. Reads `GetAttribute_Int(this, 0x3F)` (min width), + `0x3D` (max width), `0x3E` (min height), `0x3C` (max height) — all off + `this`, the SAME element `StartResizing` was called against. Since + that element is the host (per the GetParent() call above), the host's + own authored 0x3C..0x3F values are what govern every live drag. + +This matches — and completes — the "Verified resize mechanism" section +already in this doc (`UIElement::ResizeTo @0x00463C30`'s equivalent +clamp for the programmatic path): both the interactive drag +(`MouseResizeElement`) and the programmatic call (`ResizeTo`) read +0x3C..0x3F off the SAME element, and that element is always whichever +one is actually being resized — the host, never the character content +root or the slot. + +### Production wiring (`RetailUiRuntime.MountCharacter`) + +`MountCharacter` now imports `ElementInfo? hostConstraint = +LayoutImporter.ImportInfos(dats, 0x2100006Eu, 0x100005FEu)` (the same +targeted single-root overload CT1 established for row templates) and +passes it as `RetailWindowFrame.Options.DatConstraintSource`. Chrome +inset (`2 * RetailChromeSprites.Border` = 10px, NineSlice) is added +automatically by `RetailWindowFrame.ResolveConstraint`, giving the +mounted outer frame MinWidth=MaxWidth=320, MinHeight=382, MaxHeight=1010. +`ResizeX=false`/`ResizableEdges=Bottom` (already correct in the +pre-CT6 code) match the fixed-width/bottom-only-Resizebar authoring +exactly — no change needed there. + +### Titles-page list reflow + +The Titles page's own container (`0x10000539`) and its ListBox +(`0x10000532`) both already carry a REAL authored `LayoutPolicy` in the +committed/installed layout (live-verified: `LayoutPolicy is not null` +for both) that correctly stretches with the mounted content's height — +no code was needed to make the PAGE itself reflow. The only missing +piece was the ListBox's own compatibility fallback (`Anchors = +Left|Top|Bottom`, engaged only when `LayoutPolicy is null` — a no-op on +the real DAT, but needed for synthetic/test layouts and matches the +identical pattern already used for `CharacterStatController`'s +`statList`), added in `CharacterTitlesController.Bind`. + +### A latent anchor-baseline bug this slice surfaced and fixed + +`CharacterStatController.RebuildActiveList`'s (and, before CT6, only the +Skills tab's) `UiScrollablePanel` viewport is constructed with `Height = +statList.Height` **before** the window's first anchor pass ever grows +`statList` from its raw DAT-authored height (160px) up to its actual +mounted height. Left alone, the viewport's own `Left|Top|Bottom` anchor +captures its baseline margins lazily on ITS OWN first `ApplyAnchor` call +— which happens AFTER `statList` has already grown — measuring a bogus +non-zero bottom margin that then permanently caps the viewport short on +every later resize (the exact `#372`/`#412`-class bug `UiTemplateListBox +.Viewport`'s own lazy getter already works around). Fixed by calling +`viewport.CaptureCurrentAnchorBaseline()` immediately after +`statList.AddChild(viewport)`, while `viewport.Height` still exactly +equals `statList`'s own current (pre-reflow, zero-margin) height. This +was previously unexercised/untested for Skills (no test asserted its +viewport's exact height against a real window resize) and is now proven +by `CharacterStatControllerTests +.CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar`. + +### No register row + +Every number in this section is either a live-probed authored DAT value +or a structural wiring/anchor-capture-correctness fix — nothing here is +inferred or approximated. diff --git a/docs/research/2026-08-25-campaign-ct-test-script.md b/docs/research/2026-08-25-campaign-ct-test-script.md index 1e38407e..39e9c56a 100644 --- a/docs/research/2026-08-25-campaign-ct-test-script.md +++ b/docs/research/2026-08-25-campaign-ct-test-script.md @@ -73,13 +73,53 @@ Useful ACE console helpers: title grants come from quests/admin — check ## 4. Resize + scrollbar (CT6) -*(Section finalized after CT6 lands — placeholder items below.)* +Ground truth (2026-08-25 live probe against layout `0x2100006E`, host +`0x100005FE` — `docs/research/2026-08-24-campaign-ct-dat-ground-truth.md` +§CT6): the resize clamp is authored on the SHARED `gmPanelUI` host, not +the character content itself. Host authors **MinWidth=MaxWidth=310** +(fixed width — no horizontal Resizebar) and **MinHeight=372, +MaxHeight=1000**. With the NineSlice chrome's 10px inset, the MOUNTED +window's outer bounds are MinHeight≈382px, MaxHeight≈1010px, width +fixed ≈320px. -1. Resize the character window vertically: it clamps at the authored - minimum; the stat list shows its scrollbar when the shortened - viewport overflows, with retail hover/press states on the bar. -2. Other windows (chat, social) still clamp at their own authored - minimums — regression check. +1. **Grab the bottom edge and drag up (shrink).** PASS: the window + stops shrinking at its authored floor (≈382px outer / the point where + further dragging has no visible effect) — it does NOT collapse + arbitrarily small, and it does NOT refuse to shrink at all (the + pre-CT6 bug). Retail comparison: drag retail's own Character/Skills + window to its floor side-by-side; both should bottom out at + proportionally the same point relative to their starting size. +2. **Keep dragging down (grow).** PASS: the window keeps growing until + its authored ceiling (≈1010px outer) — same side-by-side comparison + against retail's own ceiling. +3. **Left/right edges do not resize.** Only the bottom edge (and top + Dragbar for moving, not resizing) responds — matches retail's + fixed-width authoring (no horizontal Resizebar). +4. **Scrollbar hand-off, Attributes tab.** At the default window size + the 9 attribute/vital rows fit without a visible scroll gap (bar + shows retail's full-track "disabled" thumb — present, not hidden, + per the 2026-08-24 scrollbar work's contract). Shrink the window + until the rows overflow: the bar's thumb shrinks proportionally and + becomes interactive (drag, click-to-page, wheel-scroll all move the + list). PASS: this is the owner's item 2 fix — previously the + scrollbar never appeared on Attributes at all. +5. **Scrollbar hand-off, Skills tab.** Same shrink/overflow check — + should already have worked pre-CT6; confirm no regression. +6. **Scrollbar hand-off, Titles tab.** With several earned titles, + shrink the window until the Titles list (authored 455px, inside the + 575px page) overflows its available space — the list's own scrollbar + (`0x10000533`) takes over the same way. +7. **Footer stays bottom-docked.** While shrinking/growing on the + Attributes/Skills tabs, the footer (raise buttons / selected-stat + info) stays pinned to the bottom edge — it does not float mid-window + or get clipped early. +8. **Grow back restores.** Drag back to the original size: the lists + return to showing all rows without scrolling (scrollbar reverts to + the full-track disabled state) and the window returns to its + original proportions. +9. Other windows (chat, social) still clamp at their own authored + minimums — regression check (chat: min 300×100, max 2000×2000 per + `CharacterPanelLiveDatTests.ChatWindowRoot_AuthorsExplicitSizeConstraints`). ## 5. Regression sweep (5 minutes) diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index 4fc8a449..bf675617 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -754,40 +754,73 @@ public static class CharacterStatController currentAttributeRows.Clear(); currentSkillRows.Clear(); - if (activeTab[0] == CharacterStatTab.Attributes) + // CT6 (2026-08-25): both tabs now stack their rows inside a + // UiScrollablePanel viewport child of statList — the SAME + // shrink-and-scroll contract the Skills tab already had. + // UiScrollablePanel.LayoutScrollableChildren (called every + // OnDraw) recomputes Scroll.ViewHeight from the viewport's OWN + // current Height every frame; the viewport's Anchors + // (Left|Top|Bottom, a child of statList) track statList's live + // Height as the window resizes, so Scroll.HasOverflow flips live + // with no extra wiring. Previously only Skills got this + // treatment — Attributes added rows directly to statList with no + // clipping/scrolling and the shared scrollbar was force-hidden, + // which is the "we never show the scrollbar on Attributes" gap + // (owner report item 2) and the reason a shrunk window could not + // show the overflowing attribute/vital rows at all. + bool isSkills = activeTab[0] == CharacterStatTab.Skills; + float contentW = isSkills + ? SkillViewportWidth(statList, skillScrollbar) + : RowContentWidth(statList); + var viewport = new UiScrollablePanel { - if (skillScrollbar is not null) - { - skillScrollbar.Model = null; - skillScrollbar.Visible = false; - } - currentAttributeRows = BuildAttributeRows(statList, rowDatFont, spriteResolve, data, attrSel, - allRaise1, allRaise10, SetFooterSelected, iconDidResolve); - activeListEntries.AddRange(currentAttributeRows); + Left = 0f, + Top = 0f, + Width = contentW, + Height = statList.Height, + LineHeight = (int)RowHeight, + Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, + }; + statList.AddChild(viewport); + // #372-class fix (same mechanism as UiTemplateListBox.Viewport's + // own lazy getter): RebuildActiveList runs during Bind, BEFORE + // the window's first anchor pass ever reflows statList up from + // its raw DAT-authored height (160px) to its actual mounted + // height. Left to its lazy default, viewport's Left|Top|Bottom + // anchor would capture its baseline margins on ITS OWN first + // ApplyAnchor call — which happens AFTER statList has already + // grown — measuring a bogus non-zero bottom margin (e.g. + // "398 grown list − 160 stale viewport = 238px margin") that then + // permanently caps the viewport 238px short on every future + // resize. Capturing NOW, while viewport.Height still exactly + // equals statList's own CURRENT (pre-reflow, zero-margin) + // height, gives a true (0,0,0,0) baseline that then correctly + // full-stretches on every later resize. + viewport.CaptureCurrentAnchorBaseline(); + activeListEntries.Add(viewport); + + if (isSkills) + { + BuildSkillRows(viewport, rowDatFont, spriteResolve, data, skillSel, + allRaise1, allRaise10, SetFooterSelected, out currentSkillRows); } else { - float contentW = SkillViewportWidth(statList, skillScrollbar); - var viewport = new UiScrollablePanel - { - Left = 0f, - Top = 0f, - Width = contentW, - Height = statList.Height, - LineHeight = (int)RowHeight, - Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, - }; - statList.AddChild(viewport); - activeListEntries.Add(viewport); + currentAttributeRows = BuildAttributeRows(viewport, rowDatFont, spriteResolve, data, attrSel, + allRaise1, allRaise10, SetFooterSelected, iconDidResolve); + } - BuildSkillRows(viewport, rowDatFont, spriteResolve, data, skillSel, - allRaise1, allRaise10, SetFooterSelected, out currentSkillRows); - - if (skillScrollbar is not null) - { - skillScrollbar.Model = viewport.Scroll; - skillScrollbar.Visible = true; - } + // Always bound + visible for whichever tab is active — retail's + // authored scrollbar gutter (0x1000023E, 281..297 within the + // 300px list) is reserved regardless of content, and UiScrollbar + // itself already draws the correct full-track "disabled" thumb + // when Model.HasOverflow is false (HideWhenDisabled defaults to + // false — see UiScrollbar.IsPresentationVisible), so no + // per-tab visibility toggle is needed here any more. + if (skillScrollbar is not null) + { + skillScrollbar.Model = viewport.Scroll; + skillScrollbar.Visible = true; } } diff --git a/src/AcDream.App/UI/Layout/CharacterTitlesController.cs b/src/AcDream.App/UI/Layout/CharacterTitlesController.cs index 77f86951..bbdfe610 100644 --- a/src/AcDream.App/UI/Layout/CharacterTitlesController.cs +++ b/src/AcDream.App/UI/Layout/CharacterTitlesController.cs @@ -182,6 +182,18 @@ public sealed class CharacterTitlesController : IDisposable // (CharacterManagementUiController.cs:463 sets its own row height // the same way for the same reason). listBox.LineHeight = 24; + // CT6 (2026-08-25): the Titles list (authored H=455 inside the + // 575px page — CT1 ground truth §3) must shrink/grow with the + // window the same way CharacterStatController's attribute/skill + // list does — same compatibility-anchor pattern, only applied when + // the imported element carries no authored edge policy of its own + // (an authored LayoutPolicy always wins). UiTemplateListBox's own + // internal viewport (created lazily inside AddItemFromTemplateList) + // already carries the #372-class eager-baseline-capture fix, so + // once the ListBox itself reflows, its scrollbar (bound below) picks + // up the new content/view relationship for free. + if (listBox.LayoutPolicy is null) + listBox.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom; uint scrollbarElementId = listBox.ScrollbarElementId; UiElement? scrollbarElement = scrollbarElementId == 0 diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index dc8539a3..59f50533 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -4109,6 +4109,24 @@ public sealed class RetailUiRuntime : IDisposable TitleTemplateResolver, _bindings.Character.SendSetTitle); + // CT6 (2026-08-25 live probe): 0x2100002E's own content root + // (0x10000227) authors NO min/max (CT1 correction (a)) — retail's + // actual resize target is the SHARED gmPanelUI host (0x100005FE in + // LayoutDesc 0x2100006E). UIElement_Resizebar::StartMouseResizing + // @0x0046B7E0 calls UIElement::StartResizing(this->GetParent(), ...) + // and stashes the drag state (m_DragStart*/m_currentBorder) ON THAT + // PARENT; UIElement::MouseResizeElement @0x00461130 then reads + // GetAttribute_Int(this, 0x3C..0x3F) off the SAME element every + // mouse-move. The bottom Resizebar (0x10000660) is a DIRECT CHILD of + // the host, not of the content parent (0x10000180) — confirmed live: + // host 0x100005FE authors MinWidth=310 MinHeight=372 MaxWidth=310 + // MaxHeight=1000 (Min==Max width: no horizontal Resizebar is + // authored, matching ResizeX=false below). + ElementInfo? hostConstraint; + lock (_bindings.Assets.DatLock) + hostConstraint = LayoutImporter.ImportInfos( + _bindings.Assets.Dats, 0x2100006Eu, 0x100005FEu); + RetailWindowHandle handle = RetailWindowFrame.Mount( Host.Root, layout.Root, @@ -4123,6 +4141,7 @@ public sealed class RetailUiRuntime : IDisposable ResizeY = true, ResizableEdges = ResizeEdges.Bottom, ConstrainResizeToParent = true, + DatConstraintSource = hostConstraint, Visible = false, ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, ContentClickThrough = false, diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs index 5935e61c..77737eaa 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs @@ -380,6 +380,63 @@ public sealed class CharacterPanelLiveDatTests Assert.Equal(2000, tree.MaxHeight); } + /// + /// CT6 (2026-08-25 live probe): the ACTUAL resize clamp source for the + /// Character/Skills window is the SHARED gmPanelUI host + /// (0x100005FE in LayoutDesc 0x2100006E) — not + /// 0x2100002E's own root (see the pin above) and not the + /// Character/Skills slot 0x1000018E either (asserted below to + /// author no constraints of its own). Decomp chain: + /// UIElement_Resizebar::StartMouseResizing @0x0046B7E0 calls + /// UIElement::StartResizing(this->GetParent(), ...), stashing + /// the drag state on that PARENT; UIElement::MouseResizeElement + /// @0x00461130 then reads GetAttribute_Int(this, 0x3C..0x3F) + /// off that SAME element every mouse-move. The bottom Resizebar + /// (0x10000660) and top Dragbar (0x1000065C) are both + /// DIRECT CHILDREN of the host (siblings of the content parent + /// 0x10000180), confirmed here — so the host's own authored + /// 0x3C..0x3F values are what RetailUiRuntime.MountCharacter + /// must plumb through as . + /// + [InstalledDatFact] + public void PanelHost_AuthorsFixedWidthAndBottomOnlyResizeContract() + { + using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + ElementInfo? host = LayoutImporter.ImportInfos(dats, 0x2100006Eu, 0x100005FEu); + Assert.NotNull(host); + Assert.Equal(310f, host!.Width); + Assert.Equal(372f, host.Height); + + // MinWidth == MaxWidth: no horizontal Resizebar is authored on this + // host (only the top Dragbar and bottom Resizebar — MountCharacter's + // ResizeX=false matches this exactly). MinHeight == the host's own + // authored default height (372) — retail's Character/Skills window + // can only grow taller, never shrink below its own authored extent. + Assert.Equal(310, host.MinWidth); + Assert.Equal(310, host.MaxWidth); + Assert.Equal(372, host.MinHeight); + Assert.Equal(1000, host.MaxHeight); + + // The Resizebar and Dragbar are DIRECT CHILDREN of the host, not the + // content parent (0x10000180) — the load-bearing parentage fact + // StartMouseResizing's GetParent() call depends on. + Assert.Contains(host.Children, c => c.Id == 0x10000660u && c.Type == 9u); // Resizebar + Assert.Contains(host.Children, c => c.Id == 0x1000065Cu && c.Type == 2u); // Dragbar + Assert.Contains(host.Children, c => c.Id == 0x10000180u); // content parent, a SIBLING + + // The Character/Skills slot itself authors no constraints of its + // own — the clamp is exclusively the host's, not layered again on + // the slot. + ElementInfo? slot = LayoutImporter.ImportInfos(dats, 0x2100006Eu, 0x1000018Eu); + Assert.NotNull(slot); + Assert.Equal(300f, slot!.Width); + Assert.Equal(362f, slot.Height); + Assert.Null(slot.MinWidth); + Assert.Null(slot.MinHeight); + Assert.Null(slot.MaxWidth); + Assert.Null(slot.MaxHeight); + } + /// /// The title-string table chain /// (CharacterTitleTable::GetCharacterTitleFromID @0x005c6ed0): diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 10809fb5..eba50d8c 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -389,8 +389,10 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter); - // Rows are UiClickablePanel which inherits UiPanel, so OfType matches. - var rows = list.Children.OfType().ToList(); + // CT6: rows now stack inside a UiScrollablePanel viewport child of + // the ListBox (same shrink-and-scroll contract Skills already had), + // so Descendants (not Children) finds them. + var rows = Descendants(list).OfType().ToList(); Assert.Equal(9, rows.Count); } @@ -402,7 +404,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter); - var rows = list.Children.OfType().ToList(); + var rows = Descendants(list).OfType().ToList(); Assert.Equal(9, rows.Count); // All rows must have an OnClick wired (not null) and ClickThrough = false. foreach (var row in rows) @@ -420,7 +422,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter); - var rows = list.Children.OfType().ToList(); + var rows = Descendants(list).OfType().ToList(); Assert.Equal(9, rows.Count); foreach (var row in rows) { @@ -439,7 +441,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter); - var rows = list.Children.OfType().ToList(); + var rows = Descendants(list).OfType().ToList(); Assert.Equal(9, rows.Count); string[] expectedNames = @@ -465,7 +467,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter); - var rows = list.Children.OfType().ToList(); + var rows = Descendants(list).OfType().ToList(); string ValueOf(UiPanel row) => row.Children.OfType().ToList()[^1].LinesProvider()[0].Text; @@ -489,7 +491,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: id => (id, 16, 16)); - var rows = list.Children.OfType().ToList(); + var rows = Descendants(list).OfType().ToList(); Assert.Equal(9, rows.Count); var iconEl = rows[0].Children.OfType().First(); @@ -507,7 +509,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: null); - var rows = list.Children.OfType().ToList(); + var rows = Descendants(list).OfType().ToList(); Assert.Equal(9, rows.Count); foreach (var row in rows) { @@ -532,7 +534,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter); - var row = list.Children.OfType().First(); + var row = Descendants(list).OfType().First(); AssertRowGeometry(row, expectedWidth: 282f); } @@ -547,7 +549,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter); - var row = list.Children.OfType().ToList()[6]; + var row = Descendants(list).OfType().ToList()[6]; AssertRowGeometry(row, expectedWidth: 282f); } @@ -699,7 +701,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter); // Simulate a click on row 4 (Focus). - var rows = list.Children.OfType().ToList(); + var rows = Descendants(list).OfType().ToList(); Assert.Equal(9, rows.Count); rows[4].OnClick!(); @@ -717,7 +719,7 @@ public class CharacterStatControllerTests (CharacterStatController.ListBoxId, list)); CharacterStatController.Bind(layout, SampleData.SampleCharacter); - list.Children.OfType().ToList()[4].OnClick!(); + Descendants(list).OfType().ToList()[4].OnClick!(); Assert.Equal("Experience To Raise:", lbl.LinesProvider()[0].Text); } @@ -732,7 +734,7 @@ public class CharacterStatControllerTests (CharacterStatController.ListBoxId, list)); CharacterStatController.Bind(layout, SampleData.SampleCharacter); - list.Children.OfType().ToList()[4].OnClick!(); + Descendants(list).OfType().ToList()[4].OnClick!(); // Focus raise cost = 110 (SampleData fixture). Assert.Equal((110L).ToString("N0"), val.LinesProvider()[0].Text); @@ -748,7 +750,7 @@ public class CharacterStatControllerTests (CharacterStatController.ListBoxId, list)); CharacterStatController.Bind(layout, SampleData.SampleCharacter); - list.Children.OfType().ToList()[4].OnClick!(); + Descendants(list).OfType().ToList()[4].OnClick!(); Assert.Equal("Unassigned Experience:", lbl.LinesProvider()[0].Text); } @@ -763,7 +765,7 @@ public class CharacterStatControllerTests (CharacterStatController.ListBoxId, list)); CharacterStatController.Bind(layout, SampleData.SampleCharacter); - list.Children.OfType().ToList()[4].OnClick!(); + Descendants(list).OfType().ToList()[4].OnClick!(); // UnassignedXp = 87_757_321_741L var expected = (87_757_321_741L).ToString("N0"); @@ -783,7 +785,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter); - var rows = list.Children.OfType().ToList(); + var rows = Descendants(list).OfType().ToList(); // Select Focus (row 4). rows[4].OnClick!(); @@ -805,7 +807,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter); - var rows = list.Children.OfType().ToList(); + var rows = Descendants(list).OfType().ToList(); // Select Endurance (row 1, value=10). rows[1].OnClick!(); @@ -826,7 +828,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter); - var rows = list.Children.OfType().ToList(); + var rows = Descendants(list).OfType().ToList(); // All rows start transparent. Assert.All(rows, r => Assert.Equal(0f, r.BackgroundColor.W)); @@ -846,7 +848,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter); - var rows = list.Children.OfType().ToList(); + var rows = Descendants(list).OfType().ToList(); rows[2].OnClick!(); // select rows[2].OnClick!(); // deselect @@ -871,7 +873,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: FakeResolve); - var rows = list.Children.OfType().ToList(); + var rows = Descendants(list).OfType().ToList(); rows[2].OnClick!(); Assert.Equal(0x06000F93u, rows[2].BackgroundSprite); // selected → sprite @@ -891,7 +893,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: FakeResolve); - var rows = list.Children.OfType().ToList(); + var rows = Descendants(list).OfType().ToList(); rows[2].OnClick!(); // select rows[2].OnClick!(); // deselect @@ -933,7 +935,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter); - list.Children.OfType().ToList()[4].OnClick!(); // select Focus + Descendants(list).OfType().ToList()[4].OnClick!(); // select Focus Assert.True(btn1.Visible, "raise×1 visible on selection"); Assert.True(btn10.Visible, "raise×10 visible on selection"); @@ -960,7 +962,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, () => sheet); - list.Children.OfType().ToList()[4].OnClick!(); + Descendants(list).OfType().ToList()[4].OnClick!(); Assert.Equal("Normal", btn1.ActiveState); Assert.Equal("Ghosted", btn10.ActiveState); @@ -980,7 +982,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter); - list.Children.OfType().ToList()[0].OnClick!(); // select Strength (cost=0) + Descendants(list).OfType().ToList()[0].OnClick!(); // select Strength (cost=0) Assert.True(btn1.Visible, "raise button visible even when disabled"); Assert.Equal("Ghosted", btn1.ActiveState); @@ -1000,7 +1002,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: (request, completed) => { requests.Add(request); completed(); }); - list.Children.OfType().ToList()[4].OnClick!(); + Descendants(list).OfType().ToList()[4].OnClick!(); btn1.OnClick!(); var request = Assert.Single(requests); @@ -1023,7 +1025,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: (request, completed) => { requests.Add(request); completed(); }); - list.Children.OfType().ToList()[4].OnClick!(); + Descendants(list).OfType().ToList()[4].OnClick!(); Assert.Equal("Normal", btn1.ActiveState); btn1.OnClick!(); @@ -1045,7 +1047,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter, onRaiseRequest: (request, completed) => { requests.Add(request); completed(); }); - list.Children.OfType().ToList()[6].OnClick!(); + Descendants(list).OfType().ToList()[6].OnClick!(); btn1.OnClick!(); var request = Assert.Single(requests); @@ -1074,7 +1076,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, () => sheet, onRaiseRequest: (request, completed) => { requests.Add(request); completed(); }); - list.Children.OfType().ToList()[4].OnClick!(); + Descendants(list).OfType().ToList()[4].OnClick!(); btn10.OnClick!(); Assert.Empty(requests); @@ -1091,7 +1093,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter); - var rows = list.Children.OfType().ToList(); + var rows = Descendants(list).OfType().ToList(); rows[4].OnClick!(); // select Assert.True(btn1.Visible); rows[4].OnClick!(); // deselect @@ -1308,7 +1310,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: id => (id, 16, 16)); - var attributeRows = list.Children.OfType().ToList(); + var attributeRows = Descendants(list).OfType().ToList(); Assert.NotEmpty(attributeRows); Assert.All(attributeRows, row => { @@ -1363,7 +1365,7 @@ public class CharacterStatControllerTests Assert.Equal(12, SkillRows(list).Count); ClickTab(layout, left: 0f); - var rows = list.Children.OfType().ToList(); + var rows = Descendants(list).OfType().ToList(); Assert.Equal(9, rows.Count); Assert.Equal("Strength", rows[0].Children.OfType().ToList()[1].LinesProvider()[0].Text); } @@ -1835,7 +1837,7 @@ public class CharacterStatControllerTests CharacterSheet Sheet() => new() { Strength = 220, AttributeBaseValues = [200, 0, 0, 0, 0, 0] }; CharacterStatController.Bind(layout, Sheet); - list.Children.OfType().ToList()[0].OnClick!(); // Strength = index 0 + Descendants(list).OfType().ToList()[0].OnClick!(); // Strength = index 0 Assert.Equal("Strength: 220 (+20)", title.LinesProvider()[0].Text); } @@ -1852,7 +1854,7 @@ public class CharacterStatControllerTests CharacterSheet Sheet() => new() { Endurance = 180, AttributeBaseValues = [0, 200, 0, 0, 0, 0] }; CharacterStatController.Bind(layout, Sheet); - list.Children.OfType().ToList()[1].OnClick!(); // Endurance = index 1 + Descendants(list).OfType().ToList()[1].OnClick!(); // Endurance = index 1 Assert.Equal("Endurance: 180 (-20)", title.LinesProvider()[0].Text); } @@ -1869,7 +1871,7 @@ public class CharacterStatControllerTests CharacterSheet Sheet() => new() { Strength = 200, AttributeBaseValues = [200, 0, 0, 0, 0, 0] }; CharacterStatController.Bind(layout, Sheet); - list.Children.OfType().ToList()[0].OnClick!(); + Descendants(list).OfType().ToList()[0].OnClick!(); Assert.Equal("Strength: 200", title.LinesProvider()[0].Text); } @@ -2145,7 +2147,7 @@ public class CharacterStatControllerTests (CharacterStatController.ListBoxId, list)); CharacterStatController.Bind(layout, SampleData.SampleCharacter); - list.Children.OfType().ToList()[0].OnClick!(); // Strength + Descendants(list).OfType().ToList()[0].OnClick!(); // Strength Assert.Equal("Infinity!", val.LinesProvider()[0].Text); } @@ -2161,7 +2163,7 @@ public class CharacterStatControllerTests (CharacterStatController.ListBoxId, list)); CharacterStatController.Bind(layout, SampleData.SampleCharacter); - list.Children.OfType().ToList()[4].OnClick!(); // Focus + Descendants(list).OfType().ToList()[4].OnClick!(); // Focus var color = title.LinesProvider()[0].Color; Assert.Equal(1f, color.X, precision: 3); @@ -2308,10 +2310,20 @@ public class CharacterStatControllerTests Assert.Equal((510f, 7f), (divider.Top, divider.Height)); Assert.Equal(3, footers.Count); Assert.All(footers, footer => Assert.Equal((520f, 55f), (footer.Top, footer.Height))); - Assert.False( + // CT6 (2026-08-25): the shared scrollbar is now always bound + visible, + // on Attributes as well as Skills — retail's authored gutter + // (0x1000023E) is reserved regardless of content, and UiScrollbar's + // own IsPresentationVisible/IsModelDisabled already draw the correct + // full-track "disabled" thumb when content fits (HideWhenDisabled + // defaults to false). The 9 attribute/vital rows (180px content) fit + // comfortably inside this fixture's 398px view, so HasOverflow is + // false here — a shrunk window is what flips it true (see the + // window-level resize test). + Assert.True( scrollbar.Visible, - $"pre-skills scrollbar: model={scrollbar.Model is not null}, " + + $"attributes-tab scrollbar: model={scrollbar.Model is not null}, " + $"resolve={scrollbar.SpriteResolve is not null}, track=0x{scrollbar.TrackSprite:X8}"); + Assert.NotNull(scrollbar.Model); ClickTab(layout, left: 92f); Assert.True(scrollbar.Visible); @@ -2326,6 +2338,160 @@ public class CharacterStatControllerTests Assert.Equal(RetailScrollbarChrome.ThumbMidRollover, scrollbar.ThumbRolloverSprite); } + /// + /// CT6 (2026-08-25): the window-level resize contract. Live-probed ground + /// truth (against layout 0x2100006E, host 0x100005FE): + /// UIElement_Resizebar::StartMouseResizing @0x0046B7E0 calls + /// UIElement::StartResizing(this->GetParent(), ...) — the bottom + /// Resizebar (0x10000660) is a DIRECT CHILD of the shared + /// gmPanelUI host, not of the content parent — and + /// UIElement::MouseResizeElement @0x00461130 reads + /// GetAttribute_Int(this, 0x3C..0x3F) off that SAME element on every + /// mouse-move. The host authors MinWidth=310 MinHeight=372 MaxWidth=310 + /// (fixed — no horizontal Resizebar authored) MaxHeight=1000. This test + /// mounts the real character content through + /// with a synthetic + /// carrying exactly those four probed values (the same synthetic- + /// pattern already uses), matching + /// production's actual wire-up in RetailUiRuntime.MountCharacter + /// (which sources the SAME host element live from the DAT). + /// + [Fact] + public void CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar() + { + var layout = FixtureLoader.LoadCharacter(); + CharacterStatController.Bind( + layout, + SampleData.SampleCharacter, + spriteResolve: id => (id, 16, 16)); + + // Tall enough that ConstrainResizeToParent (production's own setting) + // never becomes the binding constraint below — this test isolates the + // AUTHORED host clamp, not the desktop-edge clamp. + var root = new UiRoot { Width = 1280, Height = 1400 }; + RetailWindowHandle handle = RetailWindowFrame.Mount( + root, + layout.Root, + id => (id, 16, 16), + new RetailWindowFrame.Options + { + WindowName = WindowNames.Character, + Chrome = RetailWindowChrome.NineSlice, + Left = 540f, + Top = 18f, + ResizeX = false, + ResizeY = true, + ResizableEdges = ResizeEdges.Bottom, + ConstrainResizeToParent = true, + DatConstraintSource = HostConstraints(), + ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, + }); + + ApplyLayoutPass(handle.OuterFrame); + + var page = layout.Root.Children.Single( + e => e.DatElementId == CharacterStatController.AttributesPageId); + var list = Descendants(page).Single( + e => e.DatElementId == CharacterStatController.ListBoxId); + var statLayout = list.Parent!; + var scrollbar = statLayout.Children.OfType().Single( + e => e.DatElementId == CharacterStatController.ListScrollbarId); + var footer = statLayout.Children.First( + e => e.DatElementId == CharacterStatController.FooterStateAId); + var viewport = Assert.IsType(list.Children.Single()); + + // Chrome inset = 2 * RetailChromeSprites.Border (5px) = 10; the DAT's + // 310/372/310/1000 host values become 320/382/320/1010 on the + // NineSlice-wrapped outer frame. + Assert.Equal(320f, handle.OuterFrame.MinWidth); + Assert.Equal(320f, handle.OuterFrame.MaxWidth); + Assert.Equal(382f, handle.OuterFrame.MinHeight); + Assert.Equal(1010f, handle.OuterFrame.MaxHeight); + + float originalOuterHeight = handle.Height; + float originalListHeight = list.Height; + float originalFooterBottomGap = statLayout.Height - (footer.Top + footer.Height); + Assert.NotNull(scrollbar.Model); + viewport.LayoutScrollableChildren(); + Assert.False( + scrollbar.Model!.HasOverflow, + "the fixture's authored default height fits all 9 rows without scrolling"); + + // Request far below the authored minimum — the clamp must hold at + // 382, not the requested value. + handle.ResizeTo(handle.Width, 50f); + Assert.Equal(382f, handle.Height); + + ApplyLayoutPass(handle.OuterFrame); + viewport.LayoutScrollableChildren(); + + Assert.True(list.Height < originalListHeight, "the stat list must shrink with the window"); + Assert.Equal((int)MathF.Floor(viewport.Height), scrollbar.Model!.ViewHeight); + Assert.True( + scrollbar.Model!.HasOverflow, + "9 rows (180px content) must overflow the shrunk view"); + + // The footer stays bottom-docked: same distance from the stat + // layout's own bottom edge before and after the shrink. + float shrunkFooterBottomGap = statLayout.Height - (footer.Top + footer.Height); + Assert.Equal(originalFooterBottomGap, shrunkFooterBottomGap, precision: 2); + + // Request far above the authored maximum — the clamp must hold at + // 1010, not the requested value. + handle.ResizeTo(handle.Width, 5000f); + Assert.Equal(1010f, handle.Height); + + ApplyLayoutPass(handle.OuterFrame); + viewport.LayoutScrollableChildren(); + Assert.False( + scrollbar.Model!.HasOverflow, + "growing well past the content height restores no-overflow"); + + // Growing back to the ORIGINAL authored size restores the original + // list height and the no-overflow state. + handle.ResizeTo(handle.Width, originalOuterHeight); + ApplyLayoutPass(handle.OuterFrame); + viewport.LayoutScrollableChildren(); + + Assert.Equal(originalOuterHeight, handle.Height); + Assert.Equal(originalListHeight, list.Height, precision: 2); + Assert.False(scrollbar.Model!.HasOverflow); + } + + /// Synthetic authored 0x3C..0x3F constraints matching the + /// 2026-08-25 live probe of the shared gmPanelUI host + /// (0x100005FE in LayoutDesc 0x2100006E): MinWidth=310 + /// MinHeight=372 MaxWidth=310 (fixed — no horizontal Resizebar authored) + /// MaxHeight=1000. Same synthetic- shape as + /// 's own private helper. + private static ElementInfo HostConstraints() + { + var info = new ElementInfo(); + var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + direct.Properties.Values[0x3Fu] = new UiPropertyValue + { + Kind = UiPropertyKind.Integer, + IntegerValue = 310, + }; + direct.Properties.Values[0x3Eu] = new UiPropertyValue + { + Kind = UiPropertyKind.Integer, + IntegerValue = 372, + }; + direct.Properties.Values[0x3Du] = new UiPropertyValue + { + Kind = UiPropertyKind.Integer, + IntegerValue = 310, + }; + direct.Properties.Values[0x3Cu] = new UiPropertyValue + { + Kind = UiPropertyKind.Integer, + IntegerValue = 1000, + }; + info.States[UiStateInfo.DirectStateId] = direct; + return info; + } + /// /// 2026-08-24 owner report: "hovering the scrollbar and arrows does /// nothing" — root-level hover through the REAL mounted character diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs index 122d24b2..809452c1 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs @@ -512,6 +512,101 @@ public sealed class CharacterTitlesControllerTests Assert.Equal("Unknown", h.DisplayText.LinesProvider().Single().Text); } + // ── CT6 resize/scrollbar contract ────────────────────────────────── + + /// + /// CT6 (2026-08-25): the Titles list (authored H=455 inside the 575px + /// page — CT1 ground truth §3) must shrink with the window and hand off + /// to its scrollbar the same way CharacterStatController's + /// attribute/skill list does. Mounts the real fixture through + /// (same shape as + /// RetailUiRuntime.MountCharacter) and shrinks the window well + /// below the list's authored height. + /// + [Fact] + public void TitlesList_ReflowsWithWindowResize_AndScrollbarOverflowFlips() + { + // 15 rows at the authored 24px pitch = 360px content — comfortably + // under the list's own authored 455px height (fits at the fixture's + // default mounted size, matching how CharacterStatController's own + // attribute list fits its default size), but well over what remains + // once the window is shrunk below. + uint[] earnedIds = Enumerable.Range(1, 15).Select(i => (uint)i).ToArray(); + var names = earnedIds.ToDictionary(id => id, id => $"Title {id}"); + Harness h = BindWithEarnedTitles(earnedIds, displayTitleId: 0u, names: names); + Assert.Equal(15, h.Rows.Count); + + var root = new UiRoot { Width = 1280, Height = 1400 }; + RetailWindowHandle handle = RetailWindowFrame.Mount( + root, + h.Layout.Root, + id => (id, 16, 16), + new RetailWindowFrame.Options + { + WindowName = WindowNames.Character, + Chrome = RetailWindowChrome.NineSlice, + Left = 540f, + Top = 18f, + ResizeX = false, + ResizeY = true, + ResizableEdges = ResizeEdges.Bottom, + // No DatConstraintSource: this test exercises the LIST'S OWN + // reflow/scrollbar contract given an already-permitted + // resize, not the authored host clamp value itself (that is + // CharacterStatControllerTests. + // CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar's + // job) — an explicit generous MinHeight lets the window + // actually shrink instead of defaulting to its own mounted + // height (RetailWindowFrame.Mount's fallback when neither + // MinHeight nor DatConstraintSource is supplied). + MinHeight = 40f, + ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, + }); + + ApplyAnchors(handle.OuterFrame); + + var scrollbar = Assert.IsType( + h.Layout.FindElement(h.ListBox.ScrollbarElementId)); + Assert.Same(h.ListBox.Scroll, scrollbar.Model); + + float originalListHeight = h.ListBox.Height; + h.ListBox.ViewportForTest!.LayoutScrollableChildren(); + Assert.False( + h.ListBox.Scroll.HasOverflow, + "the fixture's authored default height fits all 15 rows without scrolling"); + + // Shrink the window well below the list's own authored 455px height. + // (The Titles page container 0x10000539 carries its own authored + // LayoutPolicy that already stretches with the mounted content's + // height — verified live during this test's development — so only + // the ListBox's OWN Anchors, set above by CharacterTitlesController, + // were the missing piece.) + handle.ResizeTo(handle.Width, 200f); + ApplyAnchors(handle.OuterFrame); + h.ListBox.ViewportForTest!.LayoutScrollableChildren(); + + Assert.True(h.ListBox.Height < originalListHeight, "the Titles list must shrink with the window"); + Assert.True(h.ListBox.Scroll.HasOverflow, "360px of rows must overflow the shrunk view"); + Assert.Equal((int)MathF.Floor(h.ListBox.ViewportForTest!.Height), h.ListBox.Scroll.ViewHeight); + + // Growing back restores the original height and the no-overflow state. + handle.ResizeTo(handle.Width, handle.AuthoredHeight); + ApplyAnchors(handle.OuterFrame); + h.ListBox.ViewportForTest!.LayoutScrollableChildren(); + + Assert.Equal(originalListHeight, h.ListBox.Height, precision: 2); + Assert.False(h.ListBox.Scroll.HasOverflow); + } + + private static void ApplyAnchors(UiElement parent) + { + foreach (UiElement child in parent.Children) + { + child.ApplyAnchor(parent.Width, parent.Height); + ApplyAnchors(child); + } + } + // ── Lifecycle ─────────────────────────────────────────────────────── [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs index 3d670338..84af5098 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs @@ -137,6 +137,60 @@ public sealed class RetailWindowFrameTests Assert.Equal(0.75f, frame.Opacity); } + /// + /// CT6 shared-mechanism regression pin: the authored-min/max clamp exists + /// in exactly ONE place — // + /// /, + /// set once at from + /// and then + /// enforced identically by RetailWindowManager.ResizeTo (this + /// test), the interactive drag path in UiRoot, and the persisted- + /// geometry restore clamp in RetailWindowLayoutPersistence.Apply. + /// Chat (0x2100006F) was the FIRST window to author real DAT + /// min/max (min 300/100, max 2000/2000 per + /// CharacterPanelLiveDatTests.ChatWindowRoot_AuthorsExplicitSizeConstraints); + /// this test proves an interactive-shaped ResizeTo call against a + /// chat-shaped mount still clamps at those authored bounds after CT6 + /// wired a SECOND window (Character) onto the same mechanism — a + /// regression here would mean CT6 accidentally special-cased Character + /// instead of reusing the standard path. + /// + [Fact] + public void NineSlice_ChatShapedConstraints_ClampProgrammaticResizeAtAuthoredBounds() + { + var root = new UiRoot { Width = 1920, Height = 1080 }; + var content = new UiPanel { Width = 490, Height = 100 }; + var constraints = Constraints(minHeight: 100, maxHeight: 2000); + + RetailWindowHandle handle = RetailWindowFrame.Mount( + root, + content, + NoTex, + new RetailWindowFrame.Options + { + WindowName = "chat", + Chrome = RetailWindowChrome.NineSlice, + Left = 10, + Top = 20, + ResizeX = false, + ResizeY = true, + DatConstraintSource = constraints, + }); + + // Request far below the authored minimum (100 + 10px chrome inset = + // 110) — the clamp must hold, not the requested value. + Assert.True(handle.ResizeTo(handle.Width, 5f)); + Assert.Equal(110f, handle.Height); + + // Request far above the authored maximum (2000 + 10 = 2010). + Assert.True(handle.ResizeTo(handle.Width, 50000f)); + Assert.Equal(2010f, handle.Height); + + // A request inside the bounds is honored exactly. + Assert.True(handle.ResizeTo(handle.Width, 500f)); + Assert.Equal(500f, handle.Height); + } + [Fact] public void NineSlice_CanSupplyBorderWithoutDuplicatingAuthoredCenter() { From 996cd73675a88d7015d5d464d0276a92557d3874 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 02:53:57 +0200 Subject: [PATCH 54/89] =?UTF-8?q?fix(CT6):=20fix=20round=20=E2=80=94=20chr?= =?UTF-8?q?ome-inclusive=20host=20clamp=20(BLOCKER=20B1)=20+=20372px=20mou?= =?UTF-8?q?nt=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus dual-lens review of CT6 (ec50455a) found 1 blocker, 4 should-fix, 5 notes. All applied: BLOCKER B1 — the shared gmPanelUI host (0x100005FE) IS retail's own outer window frame, not a content element: its authored 310/372/310/1000 already include the 5px bevel on every side. RetailWindowFrame.Mount was adding the NineSlice wrapper's OWN 10px chrome inset on top of that already-chrome-inclusive source, clamping MinWidth to 320 while the window's actual mounted outer width stayed 310 — silently below its own minimum until RetailWindowManager.ResizeTo forcibly widened it despite ResizeX=false. Fixed with a new RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame opt-out (chrome inset = 0 for constraint resolution only, value stays DAT-sourced); MountCharacter sets it true. Mounted clamp is now exactly the host's four raw values: width fixed 310, height 372..1000. Added a mount-time invariant (throws if the mounted outer extent falls outside its own just-computed clamp) that would have caught this at the first test run. S4 (campaign-lead ruling) — the window must MOUNT at retail's authored default, outer 372 (content 362, matching the host's own content parent 0x10000180), not 0x2100002E's own 300x600 content-authoring canvas (which produced a stale 610px default pre-fix: 600 + 10 chrome inset). 372 is exactly the host's own authored MinHeight — retail opens at its resize floor and can only be dragged taller. MountCharacter now sets ContentHeight=362f explicitly. At this default the 9 attribute/vital rows (180px) overflow the 160px list immediately — retail-correct, not a regression. S2 — 0x1000023E and 0x10000533 both author property 0x79 (HideWhenDisabled) TRUE (fixture-verified: BoolValue=true on both). A fitting list HIDES the scrollbar entirely; it does not draw a full-track "disabled" thumb. The code was already correct; four wrong descriptions (plan ledger, CharacterStatController comment, CT7 script, test comment) are corrected, plus a new IsPresentationVisible assertion pair in the resize test. S3 — CharacterTitlesController's `if (listBox.LayoutPolicy is null)` Anchors fallback was unreachable on both the real DAT and the fixture (0x10000532/0x10000539 both author HasOriginalParentSize=true, so LayoutPolicy is always assigned). Deleted; added an InstalledDatFact pin guarding the deletion against DAT drift. N4 — renamed NineSlice_ChatShapedConstraints_... to NineSlice_ContentShapedConstraints_InsetArithmeticClampsProgrammaticResize (it tested inset arithmetic on a content-shaped source, not chat's real contract) and added a true chat-contract pin mounting Chrome=Imported with chat's real 300/100/2000/2000 constraints, asserting no inset applies. N5 — corrected the "nothing inferred, no register row" sentences in the ground-truth doc and plan ledger: they were false pre-fix (the mounted clamp WAS an inferred double-counted composition); true now that B1 removes the composition. CT7 script §4 rewritten with exact clamps (no "≈"), the corrected default-overflow scrollbar behavior, and an absolute starting-height statement. Verified: full hermetic solution suite green (15,441 tests, Release, Lane exclusions per the release gate), InstalledDat lane green across the whole solution (414 tests, ACDREAM_RUN_INSTALLED_DAT_TESTS=1, Status!=KnownFailure) including two new pins (TitlesListAndPage_AuthorHasOriginalParentSize, Imported_ChatContract_ClampsAtAuthoredBoundsWithNoChromeInset). Co-Authored-By: Claude Fable 5 --- ...6-08-24-character-panel-parity-campaign.md | 122 +++++++++++++---- ...2026-08-24-campaign-ct-dat-ground-truth.md | 128 ++++++++++++++++-- .../2026-08-25-campaign-ct-test-script.md | 73 ++++++---- .../UI/Layout/CharacterStatController.cs | 19 ++- .../UI/Layout/CharacterTitlesController.cs | 26 ++-- .../UI/Layout/RetailWindowFrame.cs | 51 ++++++- src/AcDream.App/UI/RetailUiRuntime.cs | 25 ++++ .../UI/Layout/CharacterPanelLiveDatTests.cs | 32 +++++ .../UI/Layout/CharacterStatControllerTests.cs | 68 +++++++--- .../Layout/CharacterTitlesControllerTests.cs | 14 +- .../UI/Layout/RetailWindowFrameTests.cs | 114 ++++++++++++++-- 11 files changed, 552 insertions(+), 120 deletions(-) diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index 1e6878e6..7c3602bc 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -412,21 +412,74 @@ MaxHeight=1000; its bottom Resizebar (`0x10000660`) and top Dragbar `GetAttribute_Int(this, 0x3C..0x3F)` reads off that same parent. The Character/Skills slot `0x1000018E` itself authors no constraints of its own (confirmed, same pin). `RetailUiRuntime.MountCharacter` now imports -that host element and passes it as `DatConstraintSource`; the mounted -outer frame clamps at MinWidth=MaxWidth≈320, MinHeight≈382, -MaxHeight≈1010 after the NineSlice chrome inset. Full derivation + -decomp anchors: `docs/research/2026-08-24-campaign-ct-dat-ground-truth.md` -§CT6. +that host element and passes it as `DatConstraintSource`. **CORRECTED +(CT6 fix round, BLOCKER B1):** this paragraph originally claimed the +mounted outer frame clamped at "MinWidth=MaxWidth≈320, MinHeight≈382, +MaxHeight≈1010 after the NineSlice chrome inset" — that was WRONG. Host +`0x100005FE` is not a content element our wrapper adds chrome to; it IS +retail's own outer window frame (5px bevel + 300×362 content parent +`0x10000180` + 5px = 310×372), so its authored 0x3C..0x3F values are +already chrome-INCLUSIVE. Adding the NineSlice wrapper's own 10px inset +on top double-counted the bevel, clamping MinWidth to 320 while the +window's actual mounted outer width stayed 310 — silently below its own +minimum until `RetailWindowManager.ResizeTo` forcibly widened it despite +`ResizeX=false`. Fixed with a new +`RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame` opt-out +(chrome inset = 0 for constraint resolution when set — the value stays +DAT-sourced, only the redundant inset is skipped); `MountCharacter` sets +it true. The mounted outer clamps are now EXACTLY what the host authors: +width fixed **310**, height **372..1000** — no composed arithmetic. A +new mount-time invariant in `RetailWindowFrame.Mount` (throws if the +just-mounted outer extent falls outside its own just-computed clamp) +would have caught B1 at the very first test run; it is now permanent for +every window this path mounts. + +**S4 (2026-08-25, campaign-lead ruling — which number governs the +default mount size):** `0x2100002E`'s own root is authored 300×600 (the +"Size tension" the earlier ground-truth doc left unresolved — CT3's +Titles page alone is 300×575, plus the 25px tab bar). That 600 is a real +authored canvas, but it is the CONTENT's own design surface, not the +mounted default — retail scroll-clips it into the shared host's much +smaller 300×362 content parent (`0x10000180`). Pre-fix, `MountCharacter` +left `ContentHeight` unset, so it fell back to the raw 600px canvas, +producing a stale 610px mounted default (600 + 10px chrome inset) that +was never retail's actual opening size. **372 (the host's own outer +frame, 362 content + 10 chrome) is the number that governs the mount +default** — it is also exactly the host's own authored MinHeight, so +retail's Character/Skills window opens AT its resize floor and can only +be dragged taller, never shorter. `MountCharacter` now sets +`Options.ContentHeight = 362f` explicitly to realize this. The authored +page composition (header 112 + list 160 + divider + footer) IS the +362px design; at that default the 9 attribute/vital rows (180px content) +OVERFLOW the 160px list, so the stat list's scrollbar is active +immediately on open — retail-correct, not a regression (see S2 below for +what "active" actually looks like). Persistence still restores a +user-chosen size within the 372..1000 clamp on top of this default. Full +derivation + decomp anchors: +`docs/research/2026-08-24-campaign-ct-dat-ground-truth.md` §CT6. `CharacterStatController.RebuildActiveList` now wraps BOTH the Attributes and Skills tabs' rows in the same `UiScrollablePanel` viewport (previously only Skills got one; Attributes rows were added directly to the ListBox with no clipping/scrolling and the shared scrollbar was force-hidden — the owner's item 2). The shared scrollbar -is now always bound + visible; `UiScrollbar`'s own -`IsPresentationVisible`/`IsModelDisabled` already draw the correct -full-track "disabled" thumb when content fits (`HideWhenDisabled` -defaults false), so no per-tab visibility toggle is needed any more. -This surfaced and fixed a real, previously-unexercised `#372`/ +is now always BOUND (`.Model`/`.Visible = true`); no per-tab visibility +toggle is needed. **CORRECTED (CT6 fix round, S2):** this paragraph +originally claimed `UiScrollbar`'s own `IsPresentationVisible`/ +`IsModelDisabled` "draw the correct full-track 'disabled' thumb when +content fits (`HideWhenDisabled` defaults false)" — that had the +authored default BACKWARDS. `0x1000023E` (this scrollbar) and +`0x10000533` (the Titles list's own scrollbar) both author property +`0x79` (`HideWhenDisabled`) **TRUE**, fixture-verified (`BoolValue: true` +on both elements' property 121/0x79 in the committed fixture). A fitting +list HIDES the bar entirely; it does not leave a full-track disabled +thumb visible. The code was already correct — `.Visible = true` only +keeps the bar in the tree, `IsPresentationVisible` does the actual +show/hide — only this description was wrong; fixed here, in +`CharacterStatController.RebuildActiveList`'s own comment, in the CT7 +script, and in `CharacterStatControllerTests`' comment, plus a new +`IsPresentationVisible` assertion pair added to the resize test (hidden +once growing makes the content fit, visible+interactive while +overflowing). This surfaced and fixed a real, previously-unexercised `#372`/ `#412`-class anchor-baseline bug: the viewport's `Left|Top|Bottom` anchor was capturing its baseline margins lazily on its OWN first `ApplyAnchor` call, which happens AFTER the ListBox has already grown @@ -435,13 +488,19 @@ bogus non-zero margin that permanently capped the viewport short on every later resize. Fixed with an eager `viewport.CaptureCurrentAnchorBaseline()` call right after `AddChild`, mirroring the identical fix already shipped in -`UiTemplateListBox.Viewport`'s own lazy getter. `CharacterTitlesController.Bind` -gained the same defensive `Anchors = Left|Top|Bottom` fallback for the -Titles ListBox (`0x10000532`) that `CharacterStatController` already -had for its own list — live-verified as a no-op on the real DAT (both -the Titles page container and its ListBox already carry a real authored -`LayoutPolicy` that stretches correctly on its own), but matching the -established pattern for synthetic/test layouts. +`UiTemplateListBox.Viewport`'s own lazy getter. **CORRECTED (CT6 fix +round, S3):** `CharacterTitlesController.Bind` originally gained the +same defensive `if (listBox.LayoutPolicy is null) Anchors = +Left|Top|Bottom` fallback for the Titles ListBox (`0x10000532`) that +`CharacterStatController` already had for its own list. Both +`0x10000532` and the Titles page container `0x10000539` author +`HasOriginalParentSize=true` in the real DAT AND the committed fixture, +which makes `LayoutImporter`/`DatWidgetFactory` always assign a real +`LayoutPolicy` — the fallback branch was therefore UNREACHABLE, not a +harmless no-op "matching the established pattern for synthetic/test +layouts" as originally described. Deleted rather than left as dead code; +a new `CharacterPanelLiveDatTests` pin asserts `HasOriginalParentSize` +on both elements to guard the deletion against future DAT drift. STANDARDIZATION AUDIT (no gaps found, no follow-up filed): `UiElement .MinWidth/MinHeight/MaxWidth/MaxHeight`, set once at `RetailWindowFrame.Mount` from `Options.DatConstraintSource`/explicit @@ -451,10 +510,20 @@ path (`RetailWindowManager.ResizeTo`, which both `RetailPanelUiController`'s main-panel geometry sync and this slice's tests exercise), and the persisted-geometry restore clamp (`RetailWindowLayoutPersistence.Apply`). `RetailWindowFrame.Mount` remains the single production mount path (no -window bypasses it). New regression pin: -`RetailWindowFrameTests.NineSlice_ChatShapedConstraints_ClampProgrammaticResizeAtAuthoredBounds` +window bypasses it). New regression pin (**renamed, CT6 fix round N4**: +the original name `NineSlice_ChatShapedConstraints_ +ClampProgrammaticResizeAtAuthoredBounds` overclaimed — it exercises +NineSlice inset ARITHMETIC on a content-shaped source (490×100, +height-only synthetic constraints) and never actually pinned chat's real +DAT contract, since no width constraints were even set): +`RetailWindowFrameTests.NineSlice_ContentShapedConstraints_InsetArithmeticClampsProgrammaticResize` proves the same mechanism still clamps chat-shaped constraints after -Character was wired onto it. Tests: `CharacterStatControllerTests +Character was wired onto it. A new companion test, +`Imported_ChatContract_ClampsAtAuthoredBoundsWithNoChromeInset`, mounts +with `Chrome=Imported` and chat's real 300/100/2000/2000 constraints +(matching production's actual `MountChat` wiring) and asserts no inset +applies — the true chat-contract pin the renamed test's name no longer +claims to be. Tests: `CharacterStatControllerTests .CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar` (window-level: clamp at authored min/max, list shrink, scrollbar overflow flip, footer stays bottom-docked, grow-back restore) and @@ -462,9 +531,16 @@ overflow flip, footer stays bottom-docked, grow-back restore) and (same contract for the Titles list) plus the pre-existing 126+22-test suites, all updated where the new nested-viewport DOM shape required it (`Descendants(list)` instead of `list.Children` — the shape Skills rows -already needed). No register row: every number is either a live-probed -authored DAT value or a structural anchor-capture-correctness fix, -nothing inferred. +already needed). No register row: **CORRECTED (CT6 fix round, N5)** — +before the B1 fix this sentence ("every number is either a live-probed +authored DAT value ... nothing inferred") was not actually true: the +mounted 320/382/1010 clamp WAS an inference (the host's chrome-inclusive +values plus a second, redundant chrome inset composed on top). After B1 +removes that composition, the mounted clamp is now literally the host's +own four probed values with zero arithmetic applied — the sentence holds +for real. No register row for the S4 content-height default either: 362 +is the same host content-parent width/height CT6 already probed and +cited (`0x10000180`, 300×362), not a new number. **CT7 — Connected gate.** Test script (`docs/research/2026-08-25-campaign-ct-test-script.md`), owner drive: diff --git a/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md b/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md index 656e03af..1c332e57 100644 --- a/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md +++ b/docs/research/2026-08-24-campaign-ct-dat-ground-truth.md @@ -641,26 +641,122 @@ root or the slot. `MountCharacter` now imports `ElementInfo? hostConstraint = LayoutImporter.ImportInfos(dats, 0x2100006Eu, 0x100005FEu)` (the same targeted single-root overload CT1 established for row templates) and -passes it as `RetailWindowFrame.Options.DatConstraintSource`. Chrome -inset (`2 * RetailChromeSprites.Border` = 10px, NineSlice) is added -automatically by `RetailWindowFrame.ResolveConstraint`, giving the -mounted outer frame MinWidth=MaxWidth=320, MinHeight=382, MaxHeight=1010. +passes it as `RetailWindowFrame.Options.DatConstraintSource`. + +**CORRECTED (CT6 fix round, BLOCKER B1, 2026-08-25):** this section +originally claimed the NineSlice chrome inset (`2 * +RetailChromeSprites.Border` = 10px) "is added automatically by +`RetailWindowFrame.ResolveConstraint`, giving the mounted outer frame +MinWidth=MaxWidth=320, MinHeight=382, MaxHeight=1010." That was WRONG. +Host `0x100005FE` is not a bare content element our own NineSlice +wrapper adds chrome to — per the geometry dumped above, it IS retail's +own complete outer window frame: 5px bevel + the 300×362 content parent +(`0x10000180`) + 5px = 310×372 exactly. Its authored 0x3C..0x3F values +are therefore already CHROME-INCLUSIVE. Composing the wrapper's own 10px +inset on top of an already chrome-inclusive source double-counted the +bevel: the mounted window's clamp said MinWidth=320 while its actual +mounted outer width was only 310 — the window opened already violating +its own minimum, silently "fixed" at runtime only because +`RetailWindowManager.ResizeTo`'s main-panel geometry sync forcibly +widened it to 320 despite `ResizeX=false`, which would have produced a +visible 15px right-bevel seam against the other eight main panels +sharing that sync. + +**Fix:** a new opt-out, +`RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame` (default +`false`, preserving every other window's existing content-plus-chrome +behavior), tells `ResolveConstraint` to apply a chrome inset of 0 when +the DAT source is itself already the outer frame. `MountCharacter` sets +it `true` for `hostConstraint`. The mounted outer clamps are now EXACTLY +the host's four raw values, no composed arithmetic: **MinWidth= +MaxWidth=310, MinHeight=372, MaxHeight=1000.** A new mount-time +invariant in `RetailWindowFrame.Mount` (throws if the just-mounted outer +extent falls outside its own just-computed clamp) guards against this +class of bug recurring for any window this path mounts. `ResizeX=false`/`ResizableEdges=Bottom` (already correct in the pre-CT6 code) match the fixed-width/bottom-only-Resizebar authoring exactly — no change needed there. +### Which size governs the mount default (S4, campaign-lead ruling, 2026-08-25) + +The "Size tension" flagged earlier in this doc (§4, "Correction to the +plan") — `0x2100002E`'s own root authored 300×600 vs. the host slot's +300×362 — is now resolved: **372px outer (362px content) is the number +that governs the MOUNTED DEFAULT**, and 600 is the content's own +authored design canvas that retail scroll-clips into the much smaller +host slot, never the size the window actually opens at. Concretely: +`0x2100002E`'s 300×600 root exists because its Titles page alone is +authored 300×575 (Y=25 offset + the 25px tab bar — §3 above) — that is +real, authored geometry, and the Titles page correctly stretches to fill +whatever height the mounted window offers via its own `LayoutPolicy` +(confirmed below). But retail never displays that full 600px canvas at +once outside of the Titles tab's own internal scroll: the shared +`gmPanelUI` host's content parent (`0x10000180`) is fixed at 300×362, +and 372 (362 + the 10px chrome bevel) is exactly the host's own +authored MinHeight — i.e., retail's Character/Skills window OPENS at +its own resize floor and can only be dragged taller, never shorter. +Pre-fix, `MountCharacter` left `Options.ContentHeight` unset, so it fell +back to `content.Width`/`content.Height` — the raw 600px canvas — giving +a stale 610px (600 + 10 chrome) mounted default that was never retail's +actual opening size and was 238px taller than the true floor. Fixed by +setting `Options.ContentHeight = 362f` explicitly (the same host +content-parent height this section already probed and cited, not a new +number). At the corrected 372px default, the authored page composition +(header 112px + list 160px + divider + footer) is the true 362px design; +the 9 attribute/vital rows (9 × 20 = 180px content) OVERFLOW the 160px +list immediately, so the stat list's scrollbar is active from the moment +the window opens — this is retail-correct (see §S2 below for what +"active" means given the corrected `HideWhenDisabled` finding), not a +regression introduced by the fix. + ### Titles-page list reflow The Titles page's own container (`0x10000539`) and its ListBox (`0x10000532`) both already carry a REAL authored `LayoutPolicy` in the committed/installed layout (live-verified: `LayoutPolicy is not null` for both) that correctly stretches with the mounted content's height — -no code was needed to make the PAGE itself reflow. The only missing -piece was the ListBox's own compatibility fallback (`Anchors = -Left|Top|Bottom`, engaged only when `LayoutPolicy is null` — a no-op on -the real DAT, but needed for synthetic/test layouts and matches the -identical pattern already used for `CharacterStatController`'s -`statList`), added in `CharacterTitlesController.Bind`. +no code was needed to make the PAGE itself reflow. + +**CORRECTED (CT6 fix round, S3, 2026-08-25):** this section originally +went on to claim "the only missing piece was the ListBox's own +compatibility fallback (`Anchors = Left|Top|Bottom`, engaged only when +`LayoutPolicy is null` — a no-op on the real DAT, but needed for +synthetic/test layouts...), added in `CharacterTitlesController.Bind`." +That framing was wrong: both `0x10000532` and `0x10000539` author +`HasOriginalParentSize=true` (the field `DatWidgetFactory` gates +`LayoutPolicy` assignment on), confirmed both on the real installed DAT +and the committed fixture — `LayoutImporter`/`DatWidgetFactory` ALWAYS +assigns a real `LayoutPolicy` to these elements, so the `if +(listBox.LayoutPolicy is null)` branch never ran anywhere, not even in +the "synthetic/test layouts" case it was written to cover. It was dead +code, not a harmless no-op fallback. Deleted from +`CharacterTitlesController.Bind`; a new +`CharacterPanelLiveDatTests.TitlesListAndPage_AuthorHasOriginalParentSize` +pin asserts `HasOriginalParentSize` on both `0x10000532` and `0x10000539` +to guard the deletion against future DAT drift. + +### Correction to the scrollbar-visibility finding (S2, 2026-08-25) + +The CT6 landing notes (in the campaign plan ledger) claimed +`UiScrollbar`'s own `IsPresentationVisible`/`IsModelDisabled` "already +draw the correct full-track 'disabled' thumb when content fits +(`HideWhenDisabled` defaults false)." That had the authored default +BACKWARDS. Both `0x1000023E` (the shared Attributes/Skills list +scrollbar) and `0x10000533` (the Titles list's own scrollbar) author +property `0x79` (`HideWhenDisabled`) **TRUE** in the committed fixture — +verified directly against `tests/AcDream.App.Tests/UI/Layout/fixtures/character_2100002E.json`, +property `"121"` (=0x79) carries `"BoolValue": true` on both elements. A +fitting list HIDES the bar entirely (`IsPresentationVisible` false); it +does not leave a full-track "disabled" thumb on screen. The CODE in +`CharacterStatController.RebuildActiveList` was already correct — +`.Visible = true` only keeps the bar bound in the tree, and +`IsPresentationVisible` is the actual show/hide computation — only the +description was wrong. Given the S4 correction above (mount default is +now the compact 372px floor), the practical consequence is: the stat +list's scrollbar is VISIBLE and interactive from the moment the window +opens (rows overflow at the default), and DISAPPEARS once the window is +grown enough that all rows fit — the opposite of what the uncorrected +description implied. ### A latent anchor-baseline bug this slice surfaced and fixed @@ -687,3 +783,15 @@ by `CharacterStatControllerTests Every number in this section is either a live-probed authored DAT value or a structural wiring/anchor-capture-correctness fix — nothing here is inferred or approximated. + +**CORRECTED (CT6 fix round, N5, 2026-08-25):** at initial landing, this +sentence was not actually true — the mounted 320/382/1010 clamp WAS an +inference (BLOCKER B1: the host's already chrome-inclusive values with a +second, redundant chrome inset composed on top). After the B1 fix +removes that composition, the mounted clamp is now literally the host's +own four probed values (310/310/372/1000) with zero arithmetic applied, +so the sentence holds for real. The S4 content-height default (362) is +likewise not a new inferred number — it is the same host content-parent +width/height (`0x10000180`, 300×362) this section already probed and +cited above, applied to `Options.ContentHeight` instead of being left +unset. diff --git a/docs/research/2026-08-25-campaign-ct-test-script.md b/docs/research/2026-08-25-campaign-ct-test-script.md index 39e9c56a..3deadf1a 100644 --- a/docs/research/2026-08-25-campaign-ct-test-script.md +++ b/docs/research/2026-08-25-campaign-ct-test-script.md @@ -75,48 +75,61 @@ Useful ACE console helpers: title grants come from quests/admin — check Ground truth (2026-08-25 live probe against layout `0x2100006E`, host `0x100005FE` — `docs/research/2026-08-24-campaign-ct-dat-ground-truth.md` -§CT6): the resize clamp is authored on the SHARED `gmPanelUI` host, not -the character content itself. Host authors **MinWidth=MaxWidth=310** -(fixed width — no horizontal Resizebar) and **MinHeight=372, -MaxHeight=1000**. With the NineSlice chrome's 10px inset, the MOUNTED -window's outer bounds are MinHeight≈382px, MaxHeight≈1010px, width -fixed ≈320px. +§CT6, corrected by the CT6 fix round's BLOCKER B1): the resize clamp is +authored on the SHARED `gmPanelUI` host, not the character content +itself, and the host IS retail's own outer window frame — its authored +values are chrome-INCLUSIVE, not a content size our own chrome adds on +top of. Host authors **MinWidth=MaxWidth=310** (fixed width — no +horizontal Resizebar) and **MinHeight=372, MaxHeight=1000**. The +MOUNTED window's outer bounds are EXACTLY those same numbers: width +fixed **310px**, floor **372px**, ceiling **1000px** — no inset is +added on top (`RetailWindowFrame.Options.DatConstraintSourceIsOuterFrame` +now tells the mount path this source already includes the bevel). +**Starting height:** the window OPENS at retail's authored default, +**372px** — its own resize floor. It cannot open any shorter; it can +only be dragged taller. 1. **Grab the bottom edge and drag up (shrink).** PASS: the window - stops shrinking at its authored floor (≈382px outer / the point where - further dragging has no visible effect) — it does NOT collapse - arbitrarily small, and it does NOT refuse to shrink at all (the - pre-CT6 bug). Retail comparison: drag retail's own Character/Skills - window to its floor side-by-side; both should bottom out at - proportionally the same point relative to their starting size. + stops shrinking at its authored floor (372px outer / the point where + further dragging has no visible effect) — since the window already + OPENS at that floor, this step should show no shrink at all (there + is no room below the default to shrink into). It does NOT collapse + arbitrarily small. Retail comparison: drag retail's own Character/ + Skills window down from its own default; it should likewise refuse + to shrink further immediately. 2. **Keep dragging down (grow).** PASS: the window keeps growing until - its authored ceiling (≈1010px outer) — same side-by-side comparison + its authored ceiling (1000px outer) — same side-by-side comparison against retail's own ceiling. 3. **Left/right edges do not resize.** Only the bottom edge (and top Dragbar for moving, not resizing) responds — matches retail's fixed-width authoring (no horizontal Resizebar). 4. **Scrollbar hand-off, Attributes tab.** At the default window size - the 9 attribute/vital rows fit without a visible scroll gap (bar - shows retail's full-track "disabled" thumb — present, not hidden, - per the 2026-08-24 scrollbar work's contract). Shrink the window - until the rows overflow: the bar's thumb shrinks proportionally and - becomes interactive (drag, click-to-page, wheel-scroll all move the - list). PASS: this is the owner's item 2 fix — previously the - scrollbar never appeared on Attributes at all. -5. **Scrollbar hand-off, Skills tab.** Same shrink/overflow check — - should already have worked pre-CT6; confirm no regression. -6. **Scrollbar hand-off, Titles tab.** With several earned titles, - shrink the window until the Titles list (authored 455px, inside the - 575px page) overflows its available space — the list's own scrollbar - (`0x10000533`) takes over the same way. + (372px) the 9 attribute/vital rows (180px content) OVERFLOW the + 160px list — the scrollbar is ACTIVE (visible + interactive) + IMMEDIATELY on open, not after shrinking. PASS: this is the owner's + item 2 fix — previously the scrollbar never appeared on Attributes + at all. Grow the window until the rows fit without scrolling: the + bar DISAPPEARS entirely (0x1000023E authors 0x79 hide-when-disabled + TRUE — a fitting list hides the bar, it does not leave a full-track + "disabled" thumb visible). Shrink back down and the bar reappears. +5. **Scrollbar hand-off, Skills tab.** Same immediate-overflow-at- + default check (a longer skills list only makes the overflow more + obvious); grow until it fits and confirm the bar disappears the same + way. +6. **Scrollbar hand-off, Titles tab.** With several earned titles, the + Titles list (authored 455px, inside the 575px page) is scroll-clipped + into the same 372px-default window and its own scrollbar + (`0x10000533`, also hide-when-disabled — fixture-verified) takes over + the same way: active when titles overflow, hidden when the window is + grown enough that they all fit. 7. **Footer stays bottom-docked.** While shrinking/growing on the Attributes/Skills tabs, the footer (raise buttons / selected-stat info) stays pinned to the bottom edge — it does not float mid-window or get clipped early. -8. **Grow back restores.** Drag back to the original size: the lists - return to showing all rows without scrolling (scrollbar reverts to - the full-track disabled state) and the window returns to its - original proportions. +8. **Grow back restores.** Drag back down to the original default + (372px): the lists return to their default OVERFLOWING state + (scrollbar reactivates — this is the default, not "all rows fit") + and the window returns to its original proportions. 9. Other windows (chat, social) still clamp at their own authored minimums — regression check (chat: min 300×100, max 2000×2000 per `CharacterPanelLiveDatTests.ChatWindowRoot_AuthorsExplicitSizeConstraints`). diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index bf675617..550eb897 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -810,13 +810,18 @@ public static class CharacterStatController allRaise1, allRaise10, SetFooterSelected, iconDidResolve); } - // Always bound + visible for whichever tab is active — retail's - // authored scrollbar gutter (0x1000023E, 281..297 within the - // 300px list) is reserved regardless of content, and UiScrollbar - // itself already draws the correct full-track "disabled" thumb - // when Model.HasOverflow is false (HideWhenDisabled defaults to - // false — see UiScrollbar.IsPresentationVisible), so no - // per-tab visibility toggle is needed here any more. + // Always BOUND for whichever tab is active — no per-tab visibility + // toggle is needed here. CT6 fix round (S2 correction): the + // scrollbar's own .Visible=true just keeps it in the tree; what + // actually shows or hides it on screen is UiScrollbar's + // IsPresentationVisible, and 0x1000023E authors 0x79 + // (HideWhenDisabled) TRUE (fixture-verified — the 121/0x79 + // property on the fixture's scrollbar element carries + // BoolValue=true). That means a fitting list HIDES the bar + // entirely, not a full-track "disabled" thumb left visible — the + // previous comment here had the authored default backwards. + // Content that overflows still makes the bar visible and + // interactive, same as always. if (skillScrollbar is not null) { skillScrollbar.Model = viewport.Scroll; diff --git a/src/AcDream.App/UI/Layout/CharacterTitlesController.cs b/src/AcDream.App/UI/Layout/CharacterTitlesController.cs index bbdfe610..a4e7e912 100644 --- a/src/AcDream.App/UI/Layout/CharacterTitlesController.cs +++ b/src/AcDream.App/UI/Layout/CharacterTitlesController.cs @@ -183,17 +183,23 @@ public sealed class CharacterTitlesController : IDisposable // the same way for the same reason). listBox.LineHeight = 24; // CT6 (2026-08-25): the Titles list (authored H=455 inside the - // 575px page — CT1 ground truth §3) must shrink/grow with the - // window the same way CharacterStatController's attribute/skill - // list does — same compatibility-anchor pattern, only applied when - // the imported element carries no authored edge policy of its own - // (an authored LayoutPolicy always wins). UiTemplateListBox's own + // 575px page — CT1 ground truth §3) shrinks/grows with the window + // the same way CharacterStatController's attribute/skill list does. + // CT6 fix round (S3 correction): the former Anchors fallback here + // ("if LayoutPolicy is null") was DEAD CODE — both 0x10000532 (this + // ListBox) and its page container 0x10000539 author + // HasOriginalParentSize=true in the real DAT (pinned by + // CharacterPanelLiveDatTests.TitlesListAndPage_AuthorHasOriginalParentSize), + // so LayoutImporter/DatWidgetFactory ALWAYS assigns a real + // LayoutPolicy to this element and the fallback branch never ran on + // either the installed DAT or the committed fixture. The actual + // reflow mechanism is that authored LayoutPolicy stretching with the + // mounted content's height — deleted rather than left as + // unreachable/misleading compatibility code. UiTemplateListBox's own // internal viewport (created lazily inside AddItemFromTemplateList) - // already carries the #372-class eager-baseline-capture fix, so - // once the ListBox itself reflows, its scrollbar (bound below) picks - // up the new content/view relationship for free. - if (listBox.LayoutPolicy is null) - listBox.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom; + // already carries the #372-class eager-baseline-capture fix, so once + // the ListBox itself reflows, its scrollbar (bound below) picks up + // the new content/view relationship for free. uint scrollbarElementId = listBox.ScrollbarElementId; UiElement? scrollbarElement = scrollbarElementId == 0 diff --git a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs index d9b22a22..2db7d37f 100644 --- a/src/AcDream.App/UI/Layout/RetailWindowFrame.cs +++ b/src/AcDream.App/UI/Layout/RetailWindowFrame.cs @@ -43,9 +43,30 @@ public static class RetailWindowFrame /// /// Optional DAT element whose effective 0x3C..0x3F properties provide /// max-height, max-width, min-height, and min-width respectively. Explicit - /// option values win; wrapper chrome is added to DAT content constraints. + /// option values win; wrapper chrome is added to DAT content constraints + /// UNLESS is set. /// public ElementInfo? DatConstraintSource { get; init; } + + /// + /// True when is itself retail's own + /// OUTER window frame — chrome already included in its authored + /// 0x3C..0x3F values — rather than a content element the wrapper's own + /// chrome gets layered on top of. CT6 fix-round BLOCKER B1: the + /// Character window's resize clamp is authored on the shared + /// gmPanelUI host (0x100005FE, LayoutDesc + /// 0x2100006E) — that host IS retail's outer frame (5px bevel + + /// 300x362 content + 5px = 310x372), so its authored MinWidth/MinHeight/ + /// MaxWidth/MaxHeight ALREADY include the chrome our NineSlice wrapper + /// draws. Adding the wrapper's own chrome inset on top double-counts + /// the bevel and clamps the mounted window BELOW its own authored + /// minimum. Set true only when the DAT source element genuinely already + /// represents the complete outer frame, not a content root — the + /// default (false) preserves the normal content-plus-chrome behavior + /// every other window relies on. + /// + public bool DatConstraintSourceIsOuterFrame { get; init; } + public float? MinWidth { get; init; } public float? MinHeight { get; init; } public float? MaxWidth { get; init; } @@ -157,16 +178,36 @@ public static class RetailWindowFrame outerFrame.Opacity = Math.Clamp(options.Opacity, 0f, 1f); outerFrame.Visible = options.Visible; + int constraintInset = options.DatConstraintSourceIsOuterFrame ? 0 : inset; outerFrame.MinWidth = ResolveConstraint( - options.MinWidth, options.DatConstraintSource, 0x3Fu, outerWidth, inset); + options.MinWidth, options.DatConstraintSource, 0x3Fu, outerWidth, constraintInset); outerFrame.MinHeight = ResolveConstraint( - options.MinHeight, options.DatConstraintSource, 0x3Eu, outerHeight, inset); + options.MinHeight, options.DatConstraintSource, 0x3Eu, outerHeight, constraintInset); outerFrame.MaxWidth = Math.Max( outerFrame.MinWidth, - ResolveConstraint(options.MaxWidth, options.DatConstraintSource, 0x3Du, float.MaxValue, inset)); + ResolveConstraint(options.MaxWidth, options.DatConstraintSource, 0x3Du, float.MaxValue, constraintInset)); outerFrame.MaxHeight = Math.Max( outerFrame.MinHeight, - ResolveConstraint(options.MaxHeight, options.DatConstraintSource, 0x3Cu, float.MaxValue, inset)); + ResolveConstraint(options.MaxHeight, options.DatConstraintSource, 0x3Cu, float.MaxValue, constraintInset)); + + // Mount-time invariant (CT6 fix-round BLOCKER B1): a window must never + // open already violating its own clamp. This would have caught B1 at + // the first test run — the pre-fix Character mount computed outer + // bounds of 320/382/1010 (300x362 content + 10px chrome inset added + // TWICE) while its own MinWidth clamped to 320 but its actual outer + // WIDTH was only 310, silently sitting below its own minimum until + // RetailWindowManager.ResizeTo forcibly widened it despite ResizeX + // being false. + if (outerWidth < outerFrame.MinWidth || outerWidth > outerFrame.MaxWidth) + throw new InvalidOperationException( + $"RetailWindowFrame.Mount(\"{options.WindowName}\"): mounted outer width " + + $"{outerWidth} is outside its own clamp [{outerFrame.MinWidth}, {outerFrame.MaxWidth}] " + + "— the window would open already violating its authored resize bounds."); + if (outerHeight < outerFrame.MinHeight || outerHeight > outerFrame.MaxHeight) + throw new InvalidOperationException( + $"RetailWindowFrame.Mount(\"{options.WindowName}\"): mounted outer height " + + $"{outerHeight} is outside its own clamp [{outerFrame.MinHeight}, {outerFrame.MaxHeight}] " + + "— the window would open already violating its authored resize bounds."); // Capture the wrapper/content baseline at the mounted design extent now, // so a resize that occurs before the first draw uses the same margins as a diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 59f50533..6ef49d48 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -4122,6 +4122,17 @@ public sealed class RetailUiRuntime : IDisposable // host 0x100005FE authors MinWidth=310 MinHeight=372 MaxWidth=310 // MaxHeight=1000 (Min==Max width: no horizontal Resizebar is // authored, matching ResizeX=false below). + // + // CT6 fix round (2026-08-25, BLOCKER B1): host 0x100005FE is not a + // content element our NineSlice wrapper adds chrome to — it IS + // retail's own outer window frame (5px bevel + 300x362 content parent + // 0x10000180 + 5px = 310x372, verified by PanelHost_ + // AuthorsFixedWidthAndBottomOnlyResizeContract). Its authored + // 0x3C..0x3F values already include that bevel. Without + // DatConstraintSourceIsOuterFrame, RetailWindowFrame.ResolveConstraint + // added the wrapper's OWN 10px chrome inset on top — double-counting + // the bevel and clamping the mounted window's MinWidth to 320 while + // its actual outer width stayed 310, silently below its own minimum. ElementInfo? hostConstraint; lock (_bindings.Assets.DatLock) hostConstraint = LayoutImporter.ImportInfos( @@ -4137,11 +4148,25 @@ public sealed class RetailUiRuntime : IDisposable Chrome = RetailWindowChrome.NineSlice, Left = 540f, Top = 18f, + // CT6 fix round (2026-08-25, S4 ruling): mount at retail's + // authored DEFAULT — the host's own content parent + // (0x10000180) is 300x362, not 0x2100002E's own 300x600 + // authoring canvas (which retail scroll-clips into that + // slot). 362 + the 10px NineSlice inset = 372, exactly the + // host's own authored MinHeight — matching the ground-truth + // finding that retail's Character/Skills window opens AT its + // resize floor and can only be dragged taller. The 9 + // attribute/vital rows (180px) overflow the resulting 160px + // list at this default; that is retail-correct, not a bug — + // the list's own scrollbar (0x1000023E, hide-when-disabled) + // activates immediately. + ContentHeight = 362f, ResizeX = false, ResizeY = true, ResizableEdges = ResizeEdges.Bottom, ConstrainResizeToParent = true, DatConstraintSource = hostConstraint, + DatConstraintSourceIsOuterFrame = true, Visible = false, ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, ContentClickThrough = false, diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs index 77737eaa..cdc613d1 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterPanelLiveDatTests.cs @@ -305,6 +305,38 @@ public sealed class CharacterPanelLiveDatTests Assert.Equal("Ghosted", setDisplayButton.DefaultStateName); } + /// + /// CT6 fix round (S3, 2026-08-25): guards the deletion of + /// CharacterTitlesController.Bind's Anchors fallback + /// (if (listBox.LayoutPolicy is null) listBox.Anchors = ...) + /// against DAT drift. That fallback was proven UNREACHABLE — both the + /// Titles page container (0x10000539) and its ListBox + /// (0x10000532) author HasOriginalParentSize=true, the + /// field DatWidgetFactory gates real LayoutPolicy + /// assignment on (DatWidgetFactory.cs: if + /// (info.HasOriginalParentSize) e.LayoutPolicy = + /// CreateLayoutPolicy(info)), so LayoutPolicy is never + /// null for either element and the fallback branch never ran, on + /// either the installed DAT or the committed fixture. If a future + /// DAT revision ever authors either element WITHOUT + /// OriginalParentSize, this pin fails loudly — the signal to restore + /// a real fallback rather than silently losing Titles-list resize + /// behavior. + /// + [InstalledDatFact] + public void TitlesListAndPage_AuthorHasOriginalParentSize() + { + using var dats = new DatCollection(DatDirectory, DatReaderWriter.Options.DatAccessType.Read); + ElementInfo? tree = LayoutImporter.ImportInfos(dats, 0x2100002Eu); + Assert.NotNull(tree); + + ElementInfo page = Assert.Single(Flatten(tree!), e => e.Id == 0x10000539u); + Assert.True(page.HasOriginalParentSize); + + ElementInfo listBox = Assert.Single(page.Children, c => c.Id == 0x10000532u); + Assert.True(listBox.HasOriginalParentSize); + } + /// /// The title row template (0x10000536 in LayoutDesc /// 0x2100005E) — a single-line text row, no icon column, 24px diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index eba50d8c..70d0913e 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -2310,15 +2310,21 @@ public class CharacterStatControllerTests Assert.Equal((510f, 7f), (divider.Top, divider.Height)); Assert.Equal(3, footers.Count); Assert.All(footers, footer => Assert.Equal((520f, 55f), (footer.Top, footer.Height))); - // CT6 (2026-08-25): the shared scrollbar is now always bound + visible, - // on Attributes as well as Skills — retail's authored gutter - // (0x1000023E) is reserved regardless of content, and UiScrollbar's - // own IsPresentationVisible/IsModelDisabled already draw the correct - // full-track "disabled" thumb when content fits (HideWhenDisabled - // defaults to false). The 9 attribute/vital rows (180px content) fit - // comfortably inside this fixture's 398px view, so HasOverflow is - // false here — a shrunk window is what flips it true (see the - // window-level resize test). + // CT6 (2026-08-25): the shared scrollbar is now always BOUND + // (.Model set, .Visible = true), on Attributes as well as Skills — + // retail's authored gutter (0x1000023E) is reserved regardless of + // content, so no per-tab visibility toggle is needed. CT6 fix round + // (S2 correction): 0x1000023E authors 0x79 (HideWhenDisabled) TRUE + // (fixture-verified), so a FITTING list actually HIDES the bar + // entirely via IsPresentationVisible — it does not draw a full-track + // "disabled" thumb the way the previous wording here implied. This + // fixture mounts the raw 600px-tall content directly (no + // RetailWindowFrame.Mount, no S4 372px default), so the 9 + // attribute/vital rows (180px content) fit comfortably inside its + // 398px view and HasOverflow is false here — a shrunk window (or the + // real S4-corrected 372px mount default) is what flips it true and + // makes the bar presentation-visible (see the window-level resize + // test, which asserts IsPresentationVisible both ways). Assert.True( scrollbar.Visible, $"attributes-tab scrollbar: model={scrollbar.Model is not null}, " + @@ -2355,6 +2361,14 @@ public class CharacterStatControllerTests /// pattern already uses), matching /// production's actual wire-up in RetailUiRuntime.MountCharacter /// (which sources the SAME host element live from the DAT). + /// + /// CT6 fix round (2026-08-25, BLOCKER B1): the host source IS + /// retail's own outer frame — its authored 310/372/310/1000 are already + /// chrome-inclusive (5px bevel + 300x362 content + 5px = 310x372) — so + /// this test now sets + /// the same way production's MountCharacter does, and the mounted + /// clamp equals the host's four raw values EXACTLY, with no chrome inset + /// composed on top. /// [Fact] public void CharacterWindow_ResizesYWithinAuthoredHostClamp_AndReflowsListAndScrollbar() @@ -2384,6 +2398,7 @@ public class CharacterStatControllerTests ResizableEdges = ResizeEdges.Bottom, ConstrainResizeToParent = true, DatConstraintSource = HostConstraints(), + DatConstraintSourceIsOuterFrame = true, ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, }); @@ -2400,13 +2415,14 @@ public class CharacterStatControllerTests e => e.DatElementId == CharacterStatController.FooterStateAId); var viewport = Assert.IsType(list.Children.Single()); - // Chrome inset = 2 * RetailChromeSprites.Border (5px) = 10; the DAT's - // 310/372/310/1000 host values become 320/382/320/1010 on the - // NineSlice-wrapped outer frame. - Assert.Equal(320f, handle.OuterFrame.MinWidth); - Assert.Equal(320f, handle.OuterFrame.MaxWidth); - Assert.Equal(382f, handle.OuterFrame.MinHeight); - Assert.Equal(1010f, handle.OuterFrame.MaxHeight); + // CT6 fix round (BLOCKER B1): the host source IS the outer frame + // already (DatConstraintSourceIsOuterFrame = true above), so no + // chrome inset is composed on top — the mounted clamp is EXACTLY the + // DAT's own 310/372/310/1000. + Assert.Equal(310f, handle.OuterFrame.MinWidth); + Assert.Equal(310f, handle.OuterFrame.MaxWidth); + Assert.Equal(372f, handle.OuterFrame.MinHeight); + Assert.Equal(1000f, handle.OuterFrame.MaxHeight); float originalOuterHeight = handle.Height; float originalListHeight = list.Height; @@ -2418,9 +2434,9 @@ public class CharacterStatControllerTests "the fixture's authored default height fits all 9 rows without scrolling"); // Request far below the authored minimum — the clamp must hold at - // 382, not the requested value. + // 372, not the requested value. handle.ResizeTo(handle.Width, 50f); - Assert.Equal(382f, handle.Height); + Assert.Equal(372f, handle.Height); ApplyLayoutPass(handle.OuterFrame); viewport.LayoutScrollableChildren(); @@ -2430,6 +2446,12 @@ public class CharacterStatControllerTests Assert.True( scrollbar.Model!.HasOverflow, "9 rows (180px content) must overflow the shrunk view"); + // S2 correction: 0x1000023E authors 0x79 (HideWhenDisabled) TRUE — + // an overflowing list must show its scrollbar VISIBLE and + // interactive, not merely bound-but-invisible. + Assert.True( + scrollbar.IsPresentationVisible, + "an overflowing list's scrollbar must be presentation-visible (interactive), not hidden"); // The footer stays bottom-docked: same distance from the stat // layout's own bottom edge before and after the shrink. @@ -2437,15 +2459,21 @@ public class CharacterStatControllerTests Assert.Equal(originalFooterBottomGap, shrunkFooterBottomGap, precision: 2); // Request far above the authored maximum — the clamp must hold at - // 1010, not the requested value. + // 1000, not the requested value. handle.ResizeTo(handle.Width, 5000f); - Assert.Equal(1010f, handle.Height); + Assert.Equal(1000f, handle.Height); ApplyLayoutPass(handle.OuterFrame); viewport.LayoutScrollableChildren(); Assert.False( scrollbar.Model!.HasOverflow, "growing well past the content height restores no-overflow"); + // S2 correction: HideWhenDisabled TRUE means a fitting list HIDES its + // scrollbar entirely — it does NOT leave a full-track "disabled" + // thumb visible. + Assert.False( + scrollbar.IsPresentationVisible, + "content that fits after growing large must HIDE the scrollbar (0x79 HideWhenDisabled), not show a disabled thumb"); // Growing back to the ORIGINAL authored size restores the original // list height and the no-overflow state. diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs index 809452c1..07f7beb2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs @@ -576,11 +576,15 @@ public sealed class CharacterTitlesControllerTests "the fixture's authored default height fits all 15 rows without scrolling"); // Shrink the window well below the list's own authored 455px height. - // (The Titles page container 0x10000539 carries its own authored - // LayoutPolicy that already stretches with the mounted content's - // height — verified live during this test's development — so only - // the ListBox's OWN Anchors, set above by CharacterTitlesController, - // were the missing piece.) + // (CT6 fix round, S3 correction: BOTH the Titles page container + // 0x10000539 AND its own ListBox 0x10000532 carry a real authored + // LayoutPolicy — HasOriginalParentSize=true on both, pinned by + // CharacterPanelLiveDatTests.TitlesListAndPage_AuthorHasOriginalParentSize + // — that already stretches with the mounted content's height. The + // reflow this test proves comes entirely from that authored + // LayoutPolicy; CharacterTitlesController.Bind sets no Anchors of + // its own on the ListBox, since the "if LayoutPolicy is null" + // fallback it used to carry was dead code and has been deleted.) handle.ResizeTo(handle.Width, 200f); ApplyAnchors(handle.OuterFrame); h.ListBox.ViewportForTest!.LayoutScrollableChildren(); diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs index 84af5098..b6a1a653 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailWindowFrameTests.cs @@ -146,17 +146,25 @@ public sealed class RetailWindowFrameTests /// enforced identically by RetailWindowManager.ResizeTo (this /// test), the interactive drag path in UiRoot, and the persisted- /// geometry restore clamp in RetailWindowLayoutPersistence.Apply. - /// Chat (0x2100006F) was the FIRST window to author real DAT - /// min/max (min 300/100, max 2000/2000 per - /// CharacterPanelLiveDatTests.ChatWindowRoot_AuthorsExplicitSizeConstraints); - /// this test proves an interactive-shaped ResizeTo call against a - /// chat-shaped mount still clamps at those authored bounds after CT6 - /// wired a SECOND window (Character) onto the same mechanism — a - /// regression here would mean CT6 accidentally special-cased Character - /// instead of reusing the standard path. + /// + /// CT6 fix round (2026-08-25, N4 rename): this test's ORIGINAL name + /// (NineSlice_ChatShapedConstraints_ClampProgrammaticResizeAtAuthoredBounds) + /// overclaimed. It mounts a content-shaped root (490×100, roughly chat's + /// own default extent) with a synthetic height-only constraint source + /// ( sets ONLY 0x3E/0x3C — no width bounds at + /// all) — it exercises the NineSlice wrapper's chrome-inset ARITHMETIC on + /// a content-shaped source, not chat's actual authored DAT contract. Chat + /// itself mounts with Chrome = RetailWindowChrome.Imported (its + /// own root already IS the complete window, chrome included — see + /// RetailUiRuntime.MountChat), so the real chat contract needs + /// ZERO chrome inset, the opposite of what this test's NineSlice + /// wrapping exercises. See + /// + /// below for the genuine chat-shaped pin (Chrome=Imported, chat's real + /// 300/100/2000/2000 four-sided constraint). /// [Fact] - public void NineSlice_ChatShapedConstraints_ClampProgrammaticResizeAtAuthoredBounds() + public void NineSlice_ContentShapedConstraints_InsetArithmeticClampsProgrammaticResize() { var root = new UiRoot { Width = 1920, Height = 1080 }; var content = new UiPanel { Width = 490, Height = 100 }; @@ -168,7 +176,7 @@ public sealed class RetailWindowFrameTests NoTex, new RetailWindowFrame.Options { - WindowName = "chat", + WindowName = "chat-shaped", Chrome = RetailWindowChrome.NineSlice, Left = 10, Top = 20, @@ -191,6 +199,59 @@ public sealed class RetailWindowFrameTests Assert.Equal(500f, handle.Height); } + /// + /// CT6 fix round (2026-08-25, N4): the true chat-contract pin the + /// renamed test above no longer claims to be. Chat's real LayoutDesc + /// (0x2100006F, root 0x10000600) mounts with + /// Chrome = RetailWindowChrome.Imported — its own root already IS + /// the complete retail window, own dragbar/border/resize-grip children + /// included (RetailUiRuntime.MountChat) — and authors real + /// four-sided constraints, MinWidth=300, MinHeight=100, MaxWidth=2000, + /// MaxHeight=2000 + /// (CharacterPanelLiveDatTests.ChatWindowRoot_AuthorsExplicitSizeConstraints). + /// Because Imported chrome applies NO wrapper (inset = 0), the + /// mounted clamp must equal those four DAT values EXACTLY — no chrome + /// inset composed on top, the same "authored value governs verbatim" + /// contract CT6's BLOCKER B1 fix restored for the Character window's + /// chrome-inclusive host source. + /// + [Fact] + public void Imported_ChatContract_ClampsAtAuthoredBoundsWithNoChromeInset() + { + var root = new UiRoot { Width = 1920, Height = 1080 }; + var content = new UiPanel { Width = 410, Height = 100 }; + var constraints = ChatConstraints(); + + RetailWindowHandle handle = RetailWindowFrame.Mount( + root, + content, + NoTex, + new RetailWindowFrame.Options + { + WindowName = "chat", + Chrome = RetailWindowChrome.Imported, + Left = 10, + Top = 440, + ResizeX = true, + ResizeY = true, + DatConstraintSource = constraints, + }); + + Assert.Equal(300f, handle.OuterFrame.MinWidth); + Assert.Equal(100f, handle.OuterFrame.MinHeight); + Assert.Equal(2000f, handle.OuterFrame.MaxWidth); + Assert.Equal(2000f, handle.OuterFrame.MaxHeight); + + // No chrome inset: the clamp must hold at EXACTLY the authored + // 100/2000 bounds, not 110/2010 the way a NineSlice-wrapped source + // would. + Assert.True(handle.ResizeTo(handle.Width, 5f)); + Assert.Equal(100f, handle.Height); + + Assert.True(handle.ResizeTo(handle.Width, 50000f)); + Assert.Equal(2000f, handle.Height); + } + [Fact] public void NineSlice_CanSupplyBorderWithoutDuplicatingAuthoredCenter() { @@ -312,4 +373,37 @@ public sealed class RetailWindowFrameTests info.States[UiStateInfo.DirectStateId] = direct; return info; } + + /// + /// Synthetic four-sided constraint matching chat's real authored DAT + /// values (CharacterPanelLiveDatTests.ChatWindowRoot_AuthorsExplicitSizeConstraints): + /// MinWidth=300, MinHeight=100, MaxWidth=2000, MaxHeight=2000. + /// + private static ElementInfo ChatConstraints() + { + var info = new ElementInfo(); + var direct = new UiStateInfo { Id = UiStateInfo.DirectStateId }; + direct.Properties.Values[0x3Fu] = new UiPropertyValue + { + Kind = UiPropertyKind.Integer, + IntegerValue = 300, + }; + direct.Properties.Values[0x3Eu] = new UiPropertyValue + { + Kind = UiPropertyKind.Integer, + IntegerValue = 100, + }; + direct.Properties.Values[0x3Du] = new UiPropertyValue + { + Kind = UiPropertyKind.Integer, + IntegerValue = 2000, + }; + direct.Properties.Values[0x3Cu] = new UiPropertyValue + { + Kind = UiPropertyKind.Integer, + IntegerValue = 2000, + }; + info.States[UiStateInfo.DirectStateId] = direct; + return info; + } } From 6561d08fa8d068f71b3158c7bb76c457968242ff Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 02:54:56 +0200 Subject: [PATCH 55/89] =?UTF-8?q?docs(CT):=20CT6=20review-closed=20?= =?UTF-8?q?=E2=80=94=20campaign=20implementation=20complete,=20CT7=20gate?= =?UTF-8?q?=20awaiting=20owner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-24-character-panel-parity-campaign.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index 7c3602bc..7310e3bd 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -1,6 +1,6 @@ # Campaign CT — Character-panel retail parity (header identity, Titles page, resize/scrollbar, row alignment) -**Status:** PLANNED 2026-08-24 (owner gate report after the CA5/chat rounds). +**Status:** IMPLEMENTATION COMPLETE 2026-08-25 — CT1-CT6 all review-closed (per-slice Opus dual-lens review + fix round); CT7 connected gate script ready at `docs/research/2026-08-25-campaign-ct-test-script.md`, awaiting the owner's drive. NOT pushed to gitea (owner directive). **Execution model:** Fable plans and coordinates; Sonnet implements each slice; Opus runs the dual-lens review (retail-faithful + architectural) per slice, then a fix round. No pushes to gitea until the owner says so. @@ -381,7 +381,7 @@ attribute/skill rows with the authored row templates from CT1: icon placement, name/value columns, the authored right margin that reserves the scrollbar gutter. -**CT6 — Resize + scrollbar contract.** CT6 research lead (Fable, +**CT6 — Resize + scrollbar contract. REVIEW-CLOSED 2026-08-25: landed `ec50455a`, Opus review (1 blocker: chrome-inclusive host constraints double-counted; S4 default-height ruling = retail's 372px), fix round `996cd736`.** CT6 research lead (Fable, 2026-08-24, follows CT1 correction (a)): `gmPanelUI::ResizeTo @0x004BC6E0` is a bare tailcall to `UIElement::ResizeTo` — no clamp there, so the minimum lives in the generic Resizebar drag path reading From 989f665214547c7b5be7da2c3352366f1e955d6f Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 06:29:15 +0200 Subject: [PATCH 56/89] feat(CT-GF1): port retail ancestor-clip to the retained UI tree Fixes the CT7 gate finding: on the Titles tab, the authored divider 0x10000530 escapes the Character window above its top edge at the CT6-correct 372px mounted default (computed Y ~ -178, matching the owner's screenshot). Retail clips child rendering to the intersected ancestor clip-rect chain -- UIRegion::DrawHere @0x0069FA30 takes the element's screen Box2D plus a SmartArray of inherited clip rects, intersects them (the min/max clamp loop @0x0069FAA7..0x0069FB82), and draws EraseSelf/DrawChildren/DrawSelf with the intersected rect only when non-empty (the var_24 gate @0x0069FB8E). Our UiElement draw walk rendered children unclipped by default, so any authored element relying on clipping -- this divider, and the chat input row at small window sizes (the owner's earlier "text input sticks out on resize" report) -- became a visible artifact. Mechanism (element-level, reusing the existing clip-rect-stack infrastructure in UiRenderContext.PushClip/PopClip): - UiElement.ClipsChildren now defaults to TRUE for every element (was an opt-in used only by UiScrollablePanel/UiItemList). Each element's children draw AND hit-test clipped to the intersection of its own rect with the inherited ancestor clip; an element positioned outside its parent's box silently disappears, matching retail's non-empty-intersection gate. HitTest's existing early bounds check already implemented this shape for ClipsChildren=true elements -- flipping the default aligns hit-testing with the new draw-clip default in one property, per the plan's own point 4. - UiElement.ExpandsClipForPopup (default false) is the one opt-out: retail spawns a menu popup as a SEPARATE top-level region (UIElement_Menu::MakePopup), clipped only by the screen; acdream draws UiMenu's popup inline from the owning button in a second traversal (OnDrawOverlay, pre-existing -- its own doc comment already says "regardless of this element's position in the tree"). DrawOverlays now resets the accumulated clip to unbounded (UiRenderContext.PushClipUnbounded, sharing the existing clip stack) for exactly the OnDrawOverlay call of an opted-in element. UiMenu overrides ExpandsClipForPopup=>true, paired with ClipsChildren=>false so its own out-of-bounds OnHitTest union (the popup occupies ly<0 or ly>=Height depending on open direction) stays reachable through the same early-bounds gate that now defaults on for every other element. Opt-out audit (grep for OnDrawOverlay overrides + negative/overflow OnDraw coordinates across src/AcDream.App/UI): UiMenu's popup is the ONLY OnDrawOverlay override client-wide, so it is the only element needing ExpandsClipForPopup. RetailTooltipPresenter's popup and UiRoot's drag ghost both already escape structurally -- the tooltip mounts as an ordinary UiRoot CHILD (sibling of every window, clipped only by the canvas), and the drag ghost is drawn directly by UiRoot outside the tree entirely -- neither needed a code change, both are covered by new tests proving the invariant. UiResizeGrip and UiNineSlicePanel's frame/bevel draw entirely within their own [0,Width]x[0,Height] (grip flush at the window's own edges; the window's own Width/Height already represents the OUTER frame including its 5px bevel, so its ClipsChildren push already covers the frame's own content children correctly -- no negative insets found). UiScrollbar draws entirely within its own bounds (confirmed by reading OnDraw). Hit-testing: aligned with the new default via the single ClipsChildren flip (see above); UiMenu's own opt-out override keeps its popup hit-test union working, verified by the full UiMenuTests suite staying green. Divergence register: AD-113 filed for the ExpandsClipForPopup adaptation (inline popup drawing vs retail's separate top-level region). Fixed two pre-existing test-harness gaps the new default surfaced (both real bugs in the harnesses, not workarounds around the fix): - ChatLayoutConformanceTests' bottom-right-grip grow test read a STALE (pre-shrink) grip screen position because it drove two resize gestures back-to-back with no intervening Draw pass -- the only place UiElement.ApplyAnchor/LayoutPolicy.Apply run. A real frame draws every tick, so production never hits this; the test now inserts a real DrawSelfAndChildren pass between the two gestures, matching a real frame boundary. - VendorUiControllerTests' hand-built Items/Buying/Selling page containers were left at their bare 0x0 UiElement default (the harness never runs a real DAT-driven layout pass) -- harmless before ancestor clipping existed, but now hides every child of an unsized page. Sized them to the window's own content root, matching production's shape (a tab page fills the window body). Tests (all confirmed as genuine regression pins by temporarily reverting the relevant default/override and observing the exact predicted failure, then reverting back): - CharacterTitlesControllerTests.TitlesPage_Divider_ClipsAwayAtThe CT6Default_AndAppearsWhenTheWindowGrowsTaller: the literal gate repro against the real character_2100002E.json fixture through RetailWindowFrame.Mount at the CT6 372px default -- the divider renders nothing (computed Y ~ -173, matching the owner's ~-178); growing the window to 600px renders it at its authored spot. - ChatLayoutConformanceTests.ResizingTheWindowSmall_NoInputRowQuad RendersOutsideTheWindowRect: no input-row quad escapes the chat window rect at three small sizes (300x100 sanity control, 120x40/80x30 genuine pre-fix overflow -- verified failing without the fix at Y=38/55 past the window edge). - UiAncestorClipTests (new file): the core mechanism against plain synthetic elements (culled-outside / clipped-at-the-edge / hit-test parity), UiMenu's popup escaping a tiny owning window (and staying clipped while closed), and the tooltip's structural immunity (mounts as a UiRoot sibling, unaffected by a tiny ancestor window). Verification: full solution build green; hermetic suite green (--filter "Lane!=InstalledDat&Lane!=PreparedPackage&Lane!=Live& Lane!=Manual&Lane!=Timing&Lane!=Windows&Lane!=Linux& Lane!=SystemFont&Purpose!=Diagnostic&Status!=KnownFailure", 14,000+ tests across every project); InstalledDat lane green (ACDREAM_RUN_INSTALLED_DAT_TESTS=1, Status!=KnownFailure, 205+34+3+172 tests). CharacterTitlesControllerTests' existing suite and the full UiMenuTests/UiScrollbarTests suites are unaffected. src/AcDream.App/UI/UiRoot.cs carries an unrelated, pre-existing uncommitted owner probe (ACDREAM_PROBE_UI_HOVER) -- untouched by this change and deliberately left out of this commit. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 3 +- src/AcDream.App/UI/UiElement.cs | 62 +++- src/AcDream.App/UI/UiMenu.cs | 25 ++ src/AcDream.App/UI/UiRenderContext.cs | 15 + .../Layout/CharacterTitlesControllerTests.cs | 147 ++++++++++ .../UI/Layout/ChatLayoutConformanceTests.cs | 93 ++++++ .../UI/Layout/VendorUiControllerTests.cs | 14 + .../UI/UiAncestorClipTests.cs | 274 ++++++++++++++++++ 8 files changed, 627 insertions(+), 6 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/UiAncestorClipTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 157c7281..1c99a8b6 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -67,7 +67,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 86 active rows (AD-112 filed 2026-08-23 with the sky default-script port — camera-anchored synthetic script owners instead of retail's sky-cell physics objects; AD-110 filed 2026-08-17 at the entry/exit presentation round — the in-world logoff's single confirmed-echo handoff edge versus retail's two independent ExecuteLogOff/CharacterList edges, and the Tunnel-hold tail; AD-74 RETIRED 2026-08-17 at the same round — the Exit to Character Selection "behaves as Exit Game" adaptation is deleted: the confirmed grounded exit now runs the REAL retail flow (0xF653 request, server LogOut motion, 3 s hold, reverse wormhole, return to the live-connection character-select screen via LiveSessionController.CompleteCharacterLogOff), and the previously-missing indicator-bar grounded gate now runs retail's shared three-way branch; AD-109 filed 2026-08-17 at the entry/exit presentation round — the click-armed login tunnel: the wormhole presentation + enter cue now begin at the character-select Enter click instead of retail's black CreatePlayer wait, USER-DIRECTED; AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice; AD-111 (renumbered from a parallel-round AD-109 collision) filed 2026-08-17 at the systemic escape-normalization round — the appraisal report's wire-domain literal- +## 2. Adaptation (AD) — 87 active rows (AD-113 filed 2026-08-25 at Campaign CT slice CT-GF1 — `UiMenu`'s inline-drawn popup opts out of the new client-wide ancestor-clip default (`ExpandsClipForPopup`), standing in for retail's separate top-level popup region; AD-112 filed 2026-08-23 with the sky default-script port — camera-anchored synthetic script owners instead of retail's sky-cell physics objects; AD-110 filed 2026-08-17 at the entry/exit presentation round — the in-world logoff's single confirmed-echo handoff edge versus retail's two independent ExecuteLogOff/CharacterList edges, and the Tunnel-hold tail; AD-74 RETIRED 2026-08-17 at the same round — the Exit to Character Selection "behaves as Exit Game" adaptation is deleted: the confirmed grounded exit now runs the REAL retail flow (0xF653 request, server LogOut motion, 3 s hold, reverse wormhole, return to the live-connection character-select screen via LiveSessionController.CompleteCharacterLogOff), and the previously-missing indicator-bar grounded gate now runs retail's shared three-way branch; AD-109 filed 2026-08-17 at the entry/exit presentation round — the click-armed login tunnel: the wormhole presentation + enter cue now begin at the character-select Enter click instead of retail's black CreatePlayer wait, USER-DIRECTED; AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice; AD-111 (renumbered from a parallel-round AD-109 collision) filed 2026-08-17 at the systemic escape-normalization round — the appraisal report's wire-domain literal- -to-line-break shaping, which retail's `ItemExamineUI::AddItemInfo @0x004AC050` does not do (wire text appends verbatim; the escape decode retail runs at `StringInfo` resolution now lives at our string source, `DatStringResolver` → `RetailStringEscapes`); AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate @@ -209,6 +209,7 @@ readiness/requeue adaptation. See | AD-100 | **Filed 2026-08-15 at the Campaign CC CC2 review, finding F2 (unrequested `0xF643` handling).** When a `0xF643` (`CharGenVerificationResponse`) arrives with NO outstanding create/restore request, acdream DROPS the message with a once-per-session stderr log. Retail has no such gate: `Handle_CharGenVerificationResponse @0x0055E8B0` processes whatever arrives, discriminating create-vs-restore by its OWN persistent verification state (case 1 branches on `GetVerificationState() == PENDING` → new `CharacterIdentity` + `AddIdentity`, else unpacks into the existing identity at `slot`) — an unsolicited reply would be applied against whatever that state happens to be. acdream's transport-level latch (`PendingCharGenVerificationRequest`) is the equivalent discriminator, but when it is `None` there is no state to apply the reply against, so the honest move is drop-and-log rather than guessing a family. | `src/AcDream.Core.Net/WorldSession.cs` (the `CharGenVerificationResponse.ResponseOpcode` arm in `ProcessDatagram`; `_loggedUnexpectedCharGenVerificationResponse`) | Processing an unsolicited reply requires retail's persistent chargen verification state, which lives in CC3's Runtime owner, not the transport. Until then a reply with no outstanding request is either a server bug or a latch-lifecycle bug on our side — surfacing it in the log beats silently misrouting it to an arbitrary event. Pinned by `WorldSessionCharacterCreationTests.ResponseWithNoOutstandingRequest_IsDroppedAndNeverMisattributed`. | A server that sends a spontaneous/duplicate `0xF643` (ACE can double-send NameInUse — see the CC2 review's F3 note) has its second copy dropped here, where retail would re-process it. If CC3's verification gate ever needs retail's re-process semantics, this drop must move behind that owner's state. | `Handle_CharGenVerificationResponse @0x0055E8B0`; `CharGenState::GetVerificationState`; CC2 review F2 (2026-08-15) | | AD-102 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Heritage page's Viamontian button and the Town page's Sanamar button).** Retail gates BOTH controls behind `CPlayerSystem::AccountHasThroneOfDestiny`: `gmCGHeritagePage::ListenToElementMessage @ 0x00483860` shows `MakeToDWarningDialog` instead of selecting Viamontian (element `0x100003c3`) for a non-ToD account, and `gmCGTownPage::ListenToElementMessage @ 0x0047c480` does the same for Sanamar (element `0x1000040b`, `startArea` index 3 — also the reason `CharGenState::RandomizeStartArea`'s ToD-aware `RandInt(3 or 4)` bound exists). acdream's `ChargenOptions` (CC1) carries no account/DLC-ownership signal anywhere in the model, so both controls ship WITHOUT the gate — every installed heritage/town in `Options.HeritagesById`/`Options.StarterAreas` is always selectable, matching what a ToD-owning account would see. | `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`HeritageByButtonId[0x100003C3u]`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`StartAreaByButtonId[0x1000040Bu]`, `Randomize`) | ACE's server-side `CharacterCreate` handler never checks ToD ownership either (the field is purely a retail-client UI gate), so accepting the selection unconditionally never produces a request the emulator would reject; adding an account-ownership model to CC1's DAT-only `ChargenOptions` is out of this slice's scope and would need its own design (where does the "ToD owned" bit come from — account service, launcher config, a new env flag?). | None observable against ACE. A future retail-parity gate that specifically checks "does a non-ToD account get warned off Viamontian/Sanamar" will fail until an account-ownership signal exists to gate on. | `gmCGHeritagePage::ListenToElementMessage @ 0x00483860`; `gmCGTownPage::ListenToElementMessage @ 0x0047c480`; `gmCGTownPage::SetTown @ 0x0047c360`; `CharGenState::RandomizeStartArea` (DoRandom case 4, `RandInt(hasToD ? 4 : 3)`) | | AD-99 | **Filed 2026-08-15 at Campaign LA gate round 2 finding 1 (character-select Exit button).** On a confirmed Exit, acdream closes the client through the existing graceful window-close path (`d.Window.Close`, the same seam `GameplayInputCommandController`'s in-world Escape fallback already uses) instead of retail's real post-confirm behavior: `RecvNotice_CloseDialog`'s case-1 arm queues UI mode `0x10000009`, which `gmEpilogueUI::Register` claims — a brief epilogue/farewell screen — before the process actually terminates. The confirmation dialog itself (`MakeConfirmExitDialog`, its exact `ID_CharacterManagement_ConfirmExit` text, and the `m_confirmExitDialogContext != 0` re-entry guard) IS ported faithfully; only the post-confirm destination differs, the same shape as AD-74's Options-panel exit. | `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (`RequestExit`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`CharacterSelectionRuntimeBindings.RequestExit`); `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs` (`d.Window.Close` binding) | acdream has no `gmEpilogueUI` port (out of scope this round); reusing the ONE existing graceful-shutdown seam keeps `disconnected`/`exited` status events firing through `GameWindow.OnClosing` → `CompleteShutdown` rather than inventing a second shutdown path, per explicit direction for this finding. | A user confirming Exit sees the window close immediately instead of retail's brief epilogue screen; a future feature wanting to reproduce that screen (or an intermediate "logged off, returned to character select" state) has no seam yet — same gap class as AD-44. | `gmCharacterManagementUI::MakeConfirmExitDialog @0x004ed250`; `RecvNotice_CloseDialog @0x004ed760` case 1; `gmEpilogueUI::Register(0x10000009)` @0x0047a680; `gmCharacterManagementUI::OnAction @0x004ed410` (Escape key, unported — button-only this round) | +| AD-113 | **Filed 2026-08-25 at Campaign CT slice CT-GF1 (client-wide retained-UI ancestor clip).** Porting retail's `UIRegion::DrawHere @0x0069FA30` ancestor-clip intersection (an element's screen rect is intersected against the FULL inherited clip-rect chain and the subtree is skipped when the intersection is empty — the `var_24` gate @0x0069FB8E) as `UiElement.ClipsChildren`'s new client-wide default (true, threaded through the pre-existing `UiRenderContext.PushClip`/`PopClip`) needed one deliberate opt-out: retail spawns a menu's dropdown popup as a SEPARATE top-level region (`UIElement_Menu::MakePopup`), clipped only by the screen, while acdream's `UiMenu` draws its popup INLINE from the owning button in a second traversal (`OnDrawOverlay`, pre-existing, "regardless of this element's position in the tree" by its own doc comment). Without an escape, the new ancestor clip would wrongly cut off a popup that legitimately extends outside its own (possibly short) owning window — e.g. a channel dropdown opened upward past a short chat window's top edge. `UiElement.ExpandsClipForPopup` (default false) resets the accumulated clip to unbounded for exactly the `OnDrawOverlay` call of an opted-in element (`UiRenderContext.PushClipUnbounded`, sharing the existing clip stack); `UiMenu` overrides it true, paired with `ClipsChildren => false` so its own out-of-bounds `OnHitTest` union (the popup occupies `ly < 0` or `ly >= Height` depending on open direction) stays reachable through the same early-bounds gate that now defaults on for every other element. | `src/AcDream.App/UI/UiElement.cs` (`ClipsChildren`, `ExpandsClipForPopup`, `DrawOverlays`); `src/AcDream.App/UI/UiRenderContext.cs` (`PushClipUnbounded`); `src/AcDream.App/UI/UiMenu.cs` (the two overrides) | The popup is the ONLY overlay-drawing widget in the tree today (grep-confirmed: exactly one `OnDrawOverlay` override client-wide), and it already renders on top of the whole UI by construction (the overlay pass beats even rect backgrounds), so exempting it from the ancestor clip matches its existing "regardless of tree position" contract rather than introducing new behavior. | A future `OnDrawOverlay` override that is NOT a screen-anchored popup (e.g. an in-place highlight meant to stay window-clipped) would silently escape every ancestor's clip if it left `ExpandsClipForPopup` at its default; the opt-in default direction makes that the exception rather than the rule, but a widget that WANTS window-clipped overlay content has no dedicated seam beyond simply not overriding the escape. | `UIRegion::DrawHere @0x0069FA30`; `UIElement_Menu::MakePopup`; the register's own AP-201 retirement note (the FIRST `ClipsChildren`/`PushClip` port, for `UiScrollablePanel`'s viewport) | --- diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index 0a295d14..0002e869 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -544,11 +544,51 @@ public abstract class UiElement protected virtual void OnDrawOverlay(UiRenderContext ctx) { } /// - /// When true, descendant drawing and hit-testing are clipped to this element's - /// local bounds. Scrollable listboxes use this so edge rows can remain visible - /// at arbitrary pixel offsets without painting or receiving input outside the viewport. + /// Whether descendant drawing and hit-testing are clipped to this element's + /// local bounds. THIS IS THE DEFAULT (true) FOR EVERY ELEMENT — CT-GF1 port + /// of retail's ancestor-clip chain: UIRegion::DrawHere @0x0069FA30 takes + /// the element's screen Box2D plus a SmartArray<Box2D> of + /// inherited clip rects, intersects them (the min/max clamp loop + /// @0x0069FAA7..0x0069FB82), and draws — EraseSelf/DrawChildren/ + /// DrawSelf all receive the intersected rect — ONLY when the intersection + /// is non-empty (the var_24 gate @0x0069FB8E). An element positioned + /// outside its parent's box therefore silently disappears in retail, exactly + /// like / + /// (already wrapping the child-draw and child-hit-test walks below) now does for + /// every element by default, not just the scrollable listboxes that opted in + /// before this default flipped (owner gate finding: the Titles page's authored + /// divider 0x10000530 escaped the Character window at the CT6-correct 372px + /// mounted default — retail clips it away; acdream drew it floating above the + /// window). + /// + /// + /// Override to ONLY for a widget that must draw or accept + /// input beyond its own bounds by deliberate design — today just + /// , whose popup (and its own out-of-bounds + /// OnHitTest override) stands in for retail's separate top-level popup + /// region; see for the drawing half of that + /// opt-out and the divergence register row it cites. + /// /// - protected virtual bool ClipsChildren => false; + protected virtual bool ClipsChildren => true; + + /// + /// True when this element's content must ignore the + /// standard ancestor clip chain that now threads through + /// every element by default (CT-GF1). Retail spawns popups/dropdowns as SEPARATE + /// top-level regions (UIElement_Menu::MakePopup), so only the SCREEN clips + /// them — never an intervening window or panel's own client rect. Ours draws a + /// popup INLINE from its owning widget instead of reparenting to a new root (see + /// 's own doc comment — that second traversal already + /// exists so popups composite "regardless of this element's position in the + /// tree"), so without this escape hatch the new default clip would wrongly cut off + /// a popup that legitimately extends outside its owning window — e.g. a dropdown + /// opened near the bottom of a short window. Default false (ordinary overlay + /// content, if any is ever added beyond , stays clipped like + /// everything else). See the divergence register row this property's introducing + /// commit adds for the seam it stands in for. + /// + protected virtual bool ExpandsClipForPopup => false; /// Per-frame tick (animations, timers, caret blink). protected virtual void OnTick(double deltaSeconds) { } @@ -658,7 +698,19 @@ public abstract class UiElement ctx.PushAlpha(Opacity); try { - OnDrawOverlay(ctx); + // ExpandsClipForPopup (CT-GF1): a popup drawn here must ignore whatever + // ancestor clip the walk down to this element accumulated — see the + // property's own doc comment for the retail-parity rationale. + if (ExpandsClipForPopup) + { + ctx.PushClipUnbounded(); + try { OnDrawOverlay(ctx); } + finally { ctx.PopClip(); } + } + else + { + OnDrawOverlay(ctx); + } if (_children.Count > 0) { bool clipsChildren = ClipsChildren; diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index fdcf54b9..56114730 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -396,6 +396,31 @@ public sealed class UiMenu : UiElement /// must NOT be built (an invisible label child would intercept the button click). public override bool ConsumesDatChildren => true; + /// + /// CT-GF1 opt-out: 's new client-wide default + /// (true) also gates 's early "am I even inside my + /// own bounds" check — which would return null for every popup click before ever + /// reaching 's own out-of-bounds union below (the popup + /// occupies ly < 0 when it opens upward, or ly >= Height when it + /// opens downward — see 's doc). UiMenu has no real dat + /// children ( is true), so this override changes + /// nothing about child drawing/hit-testing; it exists purely to keep this element's + /// OWN out-of-bounds popup region reachable, pairing with + /// below for the drawing half of the same escape. + /// + protected override bool ClipsChildren => false; + + /// + /// CT-GF1: the popup drawn in is retail's stand-in for a + /// SEPARATE top-level region (UIElement_Menu::MakePopup) — see + /// 's own doc comment for the full + /// rationale and the divergence register row it cites. Without this, the new + /// default ancestor clip would cut off a popup that legitimately opens outside its + /// owning window (e.g. a short chat window's channel dropdown, which draws its rows + /// ABOVE the button and can extend past the window's own top edge). + /// + protected override bool ExpandsClipForPopup => true; + protected override void OnDraw(UiRenderContext ctx) { var resolve = SpriteResolve; diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs index 6abfbf83..3697f7c9 100644 --- a/src/AcDream.App/UI/UiRenderContext.cs +++ b/src/AcDream.App/UI/UiRenderContext.cs @@ -115,6 +115,21 @@ public sealed class UiRenderContext _clipStack.RemoveAt(_clipStack.Count - 1); } + /// + /// Discard every inherited clip rect for the duration of one overlay draw — + /// the escape hatch uses so a popup + /// drawn inline from its owning widget (see that property's doc comment for the + /// retail-parity rationale) is not wrongly clipped by the ancestor chain the + /// CT-GF1 default clip () now threads through + /// every other element. Shares 's stack, so pair the two + /// exactly like . + /// + public void PushClipUnbounded() + { + _clipStack.Add(_clip); + _clip = null; + } + /// Route subsequent draws to the overlay layer (flushed on top of the whole /// UI). Used by the root for the popup/overlay traversal. Pair with . public void BeginOverlayLayer() => TextRenderer.OverlayMode = true; diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs index 07f7beb2..27424699 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs @@ -1,4 +1,7 @@ using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Tests.Rendering.Gpu; using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Runtime; @@ -611,6 +614,150 @@ public sealed class CharacterTitlesControllerTests } } + // ── CT-GF1 ancestor-clip gate repro ───────────────────────────────── + + /// + /// OWNER GATE FINDING (screenshots on file, Campaign CT slice CT-GF1): + /// on the Titles tab, the page's authored divider 0x10000530 (300x9, + /// authored Y=60 in the 575px page, top-edge mode 2 = bottom-anchored at + /// 515px from the page bottom) escapes the window at the CT6-correct + /// 372px mounted default — the page is only ~337px tall there, so the + /// divider's bottom-anchor math computes a NEGATIVE Y and renders ABOVE + /// the window entirely. Retail clips this away + /// (UIRegion::DrawHere @0x0069FA30's ancestor-clip rect + /// intersection, non-empty gate @0x0069FB8E); acdream drew it floating + /// above the window before this fix. Sibling divider 0x10000534 + /// (authored Y=550) has the same shape but lands harmlessly at this + /// size — both share sprite 0x06001420, which is why the assertions + /// below key on Y-RANGE (this divider's own resolved screen position), + /// not texture. + /// + /// Mounts the real fixture through the same + /// production shape as RetailUiRuntime.MountCharacter (372px + /// default: ContentHeight=362f + the 10px NineSlice inset), switches to + /// the REAL Titles tab via 's own + /// click handler (not a manual Visible poke — the CT3 tab-switch + /// closure this file's own CharacterTabs_UseImportedChromeWithout... + /// sibling test already exercises), and draws through a + /// . Unlike 's + /// harness (which resolves every sprite to texture 0 for the OTHER + /// Titles tests in this file — sufficient for their geometry/wiring + /// assertions), this test resolves real non-zero textures so + /// UiDatElement.OnDraw actually queues quad geometry to inspect. + /// + [Fact] + public void TitlesPage_Divider_ClipsAwayAtTheCT6Default_AndAppearsWhenTheWindowGrowsTaller() + { + ImportedLayout layout = LayoutImporter.Build( + FixtureLoader.LoadCharacterInfos(), id => (id, 8, 8), null); + CharacterStatController.Bind( + layout, SampleData.SampleCharacter, spriteResolve: id => (id, 8, 8)); + + var titlesTab = Assert.IsType( + layout.FindElement(CharacterStatController.TabTitlesId)); + Assert.NotNull(titlesTab.OnClick); + titlesTab.OnClick!(); // the REAL tab-switch path — flips TitlesPage.Visible + + UiElement divider = UiElement.FindDescendant(layout.Root, 0x10000530u)!; + Assert.NotNull(divider); + UiElement siblingDivider = UiElement.FindDescendant(layout.Root, 0x10000534u)!; + Assert.NotNull(siblingDivider); + + var screen = new UiRoot { Width = 1600f, Height = 1200f }; + RetailWindowHandle handle = RetailWindowFrame.Mount( + screen, + layout.Root, + id => (id, 8, 8), + new RetailWindowFrame.Options + { + WindowName = WindowNames.Character, + Chrome = RetailWindowChrome.NineSlice, + Left = 0f, + Top = 0f, + // CT6's own corrected default: the host's content parent is + // 300x362, not 0x2100002E's raw 300x600 authoring canvas. + ContentHeight = 362f, + MinWidth = 310f, + MaxWidth = 310f, + MinHeight = 372f, + MaxHeight = 1000f, + ResizeX = false, + ResizeY = true, + ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, + }); + Assert.Equal(372f, handle.Height); // the CT6-correct mounted default + + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(screen.Width, screen.Height)); + var ctx = new UiRenderContext(renderer, new Vector2(screen.Width, screen.Height)); + handle.OuterFrame.DrawSelfAndChildren(ctx); + + // At the 372px default the divider's computed Y must be negative + // (above the window) — the owner's reported Y≈-178 shape. + Vector2 dividerAtDefault = divider.ScreenPosition; + Assert.True( + dividerAtDefault.Y + divider.Height <= 0f, + "expected the Titles divider to compute a Y above the window at the 372px " + + $"default (owner-reported ≈-178); got {dividerAtDefault.Y}"); + // Nothing at all may render meaningfully above the window's own top + // edge (Y=0 itself is the window's own top border/frame, not "above + // the window") — the exact shape of the owner's screenshot finding. + AssertNoQuadCoversY(renderer, -10_000f, -1f); + + // Grow the window taller. A real frame draws every tick, which is + // what reflows a bottom-anchored child against its parent's CURRENT + // size (UiElement.ApplyAnchor / LayoutPolicy.Apply run only from + // DrawSelfAndChildren) — two passes, matching the CT6 sibling test's + // own raw-edge-LayoutPolicy "policies settle" pattern above. + handle.OuterFrame.Height = 600f; + renderer.Begin(new Vector2(screen.Width, screen.Height)); + handle.OuterFrame.DrawSelfAndChildren(ctx); + renderer.Begin(new Vector2(screen.Width, screen.Height)); + handle.OuterFrame.DrawSelfAndChildren(ctx); + + Vector2 dividerGrown = divider.ScreenPosition; + Assert.True( + dividerGrown.Y >= 0f && dividerGrown.Y + divider.Height <= 600f, + "expected the Titles divider to land inside the grown window at its authored " + + $"spot; got {dividerGrown.Y}"); + AssertQuadCoversY(renderer, dividerGrown.Y, dividerGrown.Y + divider.Height); + } + + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource + { + public IGpuFrame? CurrentFrame => null; + } + + private static void AssertNoQuadCoversY(TextRenderer renderer, float yLo, float yHi) + { + foreach (var seg in renderer.DebugSpriteSegmentVerts) + { + for (int i = 0; i < seg.Verts.Count / 8; i++) + { + float vy = seg.Verts[i * 8 + 1]; + Assert.False( + vy > yLo - 0.01f && vy < yHi + 0.01f, + $"unexpected quad vertex at Y={vy} inside the clipped-away range " + + $"[{yLo},{yHi}] (texture {seg.Texture})"); + } + } + } + + private static void AssertQuadCoversY(TextRenderer renderer, float yLo, float yHi) + { + bool found = renderer.DebugSpriteSegmentVerts.Any(seg => + { + for (int i = 0; i < seg.Verts.Count / 8; i++) + { + float vy = seg.Verts[i * 8 + 1]; + if (vy >= yLo - 0.5f && vy <= yHi + 0.5f) return true; + } + return false; + }); + Assert.True(found, $"expected at least one quad in Y range [{yLo},{yHi}]"); + } + // ── Lifecycle ─────────────────────────────────────────────────────── [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs index bb7ff5b6..b21ca13a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs @@ -603,6 +603,23 @@ public class ChatLayoutConformanceTests Assert.Equal(390f, handle.Width); Assert.Equal(100f, handle.Height); + // CT-GF1: a real frame draws every tick, which is what reflows an + // anchored child's Left/Top against its parent's CURRENT size + // (UiElement.ApplyAnchor runs only from DrawSelfAndChildren) — so by + // the time a player's next click lands, the grip is already + // repositioned for the just-shrunk window. This test drives the resize + // directly without an intervening render, so without this draw pass the + // grip's ScreenPosition below stays at its PRE-shrink (now stale, wider) + // anchor and lands outside the shrunk window's own bounds — UiElement's + // new default ancestor clip (ClipsChildren) then refuses the press + // before it ever reaches the grip. Matches a real frame boundary, not a + // workaround for the clip. + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(root.Width, root.Height)); + var drawCtx = new UiRenderContext(renderer, new Vector2(root.Width, root.Height)); + root.DrawSelfAndChildren(drawCtx); + // Now grow from the shrunken state — this is the reported-broken direction. var brGripAfterShrink = Assert.IsType(layout.FindElement(0x100006A1u)); var gs2 = brGripAfterShrink.ScreenPosition; @@ -732,6 +749,82 @@ public class ChatLayoutConformanceTests $"input ends {input.Left + input.Width} past send start {send.Left}"); } + /// + /// CT-GF1 regression pin — companion to + /// above, which + /// only proves the input row's Left/Width GEOMETRY stays inside the + /// window: that test's LayoutImporter.Build call resolves every + /// sprite through (texture 0), and + /// UiDatElement.OnDraw's own tex == 0 guard means nothing + /// ever reaches a quad — exactly why the owner's "text input sticks out + /// on resize" report was previously unreproducible in a fixture. This + /// test resolves REAL non-zero textures (same id => (id, 8, 8) + /// pattern as MountedChatWindow_LiveGrip_ActuallyEmitsASpriteDraw_ + /// NotJustResolvesSpriteFile above) and draws the whole mounted + /// window through a at small sizes, then + /// asserts every emitted quad's vertices stay inside the window's own + /// [0,width]x[0,height] rect. The mechanism CT-GF1 ports + /// (UiElement.ClipsChildren's new client-wide default, + /// retail's UIRegion::DrawHere @0x0069FA30 ancestor-clip + /// intersection) is what makes this true now — confirmed a real + /// regression pin (not vacuous) by temporarily reverting the default: + /// the 120x40/80x30 cases fail without the fix (a quad renders ~15-38px + /// past the window's bottom edge, the input row's authored ~72px extent + /// no longer fitting a window shrunk below the ~100px it was designed + /// for) and pass with it; 300x100 is the "still comfortably fits, sanity" + /// control case. + /// + [Theory] + [InlineData(300f, 100f)] + [InlineData(120f, 40f)] + [InlineData(80f, 30f)] + public void ResizingTheWindowSmall_NoInputRowQuadRendersOutsideTheWindowRect(float width, float height) + { + var infos = FixtureLoader.LoadChatInfos(); + ImportedLayout layout = LayoutImporter.Build(infos, id => (id, 8, 8), null); + var controller = ChatWindowController.Bind( + infos, layout, new ChatVM(new ChatLog()), () => NullCommandBus.Instance, + new ChatWindowState(), null, null, NoTex); + Assert.NotNull(controller); + UiElement window = layout.FindElement(0x10000600u)!; + var root = new UiRoot { Width = 800f, Height = 600f }; + root.AddChild(window); + + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(root.Width, root.Height)); + var ctx = new UiRenderContext(renderer, new Vector2(root.Width, root.Height)); + window.DrawSelfAndChildren(ctx); + + window.Width = width; + window.Height = height; + window.ResetAnchorCapture(); + // Two frames — same "raw-edge LayoutPolicy needs a settle pass" reasoning + // as the geometry sibling test above. + renderer.Begin(new Vector2(root.Width, root.Height)); + window.DrawSelfAndChildren(ctx); + renderer.Begin(new Vector2(root.Width, root.Height)); + window.DrawSelfAndChildren(ctx); + + float windowLeft = window.ScreenPosition.X; + float windowTop = window.ScreenPosition.Y; + const float Slop = 0.5f; + foreach (var seg in renderer.DebugSpriteSegmentVerts) + { + for (int i = 0; i < seg.Verts.Count / 8; i++) + { + float vx = seg.Verts[i * 8]; + float vy = seg.Verts[i * 8 + 1]; + Assert.True( + vx >= windowLeft - Slop && vx <= windowLeft + width + Slop + && vy >= windowTop - Slop && vy <= windowTop + height + Slop, + $"quad vertex ({vx},{vy}) escapes the {width}x{height} chat window rect " + + $"[{windowLeft},{windowTop}]-[{windowLeft + width},{windowTop + height}] " + + $"(texture {seg.Texture})"); + } + } + } + private static void ApplyLayoutPassLocal(UiElement parent) { foreach (var child in parent.Children) diff --git a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs index d37c6585..389fafc2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs @@ -274,6 +274,20 @@ public sealed class VendorUiControllerTests root.AddChild(ItemsPage); root.AddChild(BuyingPage); root.AddChild(SellingPage); + // CT-GF1: UiElement.ClipsChildren now defaults to true (retail's + // UIRegion::DrawHere ancestor-clip port) — a page container's + // CHILDREN are unreachable by draw or hit-test once the container's + // own Width/Height clips them away. This hand-built harness never ran + // a real DAT-driven layout pass, so these bare TestElement pages were + // left at their 0x0 default; that was harmless before this default + // flipped (nothing clipped, so a 0x0 "page" still let its children + // draw/hit-test anywhere) but now hides every child of an unsized + // page, matching production's shape (a tab page fills the window + // body below the tab strip) — not a workaround, just giving the + // hand-built fixture the geometry a real mounted page always has. + ItemsPage.Width = root.Width; ItemsPage.Height = root.Height; + BuyingPage.Width = root.Width; BuyingPage.Height = root.Height; + SellingPage.Width = root.Width; SellingPage.Height = root.Height; ItemsPage.AddChild(ItemList); ItemsPage.AddChild(ItemScrollbar); ItemsPage.AddChild(TypeMenu); diff --git a/tests/AcDream.App.Tests/UI/UiAncestorClipTests.cs b/tests/AcDream.App.Tests/UI/UiAncestorClipTests.cs new file mode 100644 index 00000000..e240990c --- /dev/null +++ b/tests/AcDream.App.Tests/UI/UiAncestorClipTests.cs @@ -0,0 +1,274 @@ +using System.Linq; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.App.UI; +using AcDream.App.UI.Layout; + +namespace AcDream.App.Tests.UI; + +/// +/// Campaign CT slice CT-GF1: mechanism-level tests for the client-wide retained-UI +/// ancestor clip ('s new default-true, porting +/// retail's UIRegion::DrawHere @0x0069FA30 clip-rect-chain intersection) and its +/// one deliberate opt-out (, used by +/// 's inline-drawn popup). The Titles-page divider gate repro lives +/// in CharacterTitlesControllerTests (the real owner-reported symptom); the chat +/// input-row regression pin lives in ChatLayoutConformanceTests. This file covers +/// the underlying mechanism directly with small synthetic trees. +/// +public sealed class UiAncestorClipTests +{ + private sealed class TestElement : UiElement { } + + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource + { + public IGpuFrame? CurrentFrame => null; + } + + private static (RecordingGpuDevice device, TextRenderer renderer, UiRenderContext ctx) MakeContext( + float w, float h) + { + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(w, h)); + var ctx = new UiRenderContext(renderer, new Vector2(w, h)); + return (device, renderer, ctx); + } + + private static bool AnyQuadAt(TextRenderer renderer, System.Func predicate) + { + foreach (var seg in renderer.DebugSpriteSegmentVerts) + { + for (int i = 0; i < seg.Verts.Count / 8; i++) + { + if (predicate(seg.Verts[i * 8], seg.Verts[i * 8 + 1])) + return true; + } + } + return false; + } + + /// + /// Core mechanism, plain elements (no dat import involved): a child positioned + /// entirely outside its parent's [0,Width]x[0,Height] rect renders NOTHING — the + /// retail UIRegion::DrawHere "intersection empty -> skip the subtree" gate + /// (@0x0069FB8E). A sibling positioned INSIDE the parent still renders normally. + /// + [Fact] + public void ChildOutsideParentBounds_RendersNothing_SiblingInsideStillRenders() + { + var parent = new TestElement { Width = 50f, Height = 50f }; + var outside = new UiSolidSpriteFill + { + Left = -100f, Top = -100f, Width = 20f, Height = 20f, + SpriteId = 7u, + SpriteResolve = id => (id, 8, 8), + }; + var inside = new UiSolidSpriteFill + { + Left = 5f, Top = 5f, Width = 10f, Height = 10f, + SpriteId = 9u, + SpriteResolve = id => (id, 8, 8), + }; + parent.AddChild(outside); + parent.AddChild(inside); + + var (_, renderer, ctx) = MakeContext(200f, 200f); + parent.DrawSelfAndChildren(ctx); + + Assert.DoesNotContain(renderer.DebugSpriteSegmentVerts, s => s.Texture == 7u); + Assert.Contains(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u); + } + + /// + /// A child straddling the parent's edge is clipped to the visible sliver, not + /// culled outright and not drawn full-size — the intersected rect + /// UIRegion::DrawHere passes to DrawSelf. + /// + [Fact] + public void ChildStraddlingParentEdge_ClipsToTheVisibleSliver() + { + var parent = new TestElement { Width = 50f, Height = 50f }; + var straddling = new UiSolidSpriteFill + { + Left = 40f, Top = 10f, Width = 30f, Height = 10f, // spans x=[40,70), parent ends at 50 + SpriteId = 3u, + SpriteResolve = id => (id, 8, 8), + }; + parent.AddChild(straddling); + + var (_, renderer, ctx) = MakeContext(200f, 200f); + parent.DrawSelfAndChildren(ctx); + + var seg = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 3u); + float maxX = 0f; + for (int i = 0; i < seg.Verts.Count / 8; i++) + maxX = System.MathF.Max(maxX, seg.Verts[i * 8]); + Assert.True(maxX <= 50.01f, $"clipped quad's rightmost X ({maxX}) must not exceed the parent's edge (50)"); + } + + /// + /// Hit-testing gets the SAME default: a point outside the parent's bounds never + /// reaches a child positioned there, even though the child's own local hit-test + /// would otherwise accept it (aligning HitTest with the new draw-clip default per + /// the CT-GF1 plan's point 4). + /// + [Fact] + public void ChildOutsideParentBounds_IsNeverHit() + { + var parent = new TestElement { Width = 50f, Height = 50f }; + var outside = new TestElement { Left = -30f, Top = -30f, Width = 20f, Height = 20f }; + parent.AddChild(outside); + + UiElement? hit = parent.HitTest(-20f, -20f); // lands inside `outside`'s own local rect + + Assert.Null(hit); + } + + /// + /// CT-GF1's one opt-out: 's popup (drawn inline via + /// OnDrawOverlay) must keep escaping its owning window's ancestor clip — + /// retail's separate top-level popup region, see 's + /// doc comment and the AD-113 divergence register row. A small window (80x18, the + /// menu button's own size) sits well below the canvas top; the popup opens UPWARD + /// (the class default) and must still render there, well outside the window's own + /// [0,80]x[0,18] rect. + /// + [Fact] + public void UiMenuPopup_StillRendersOutsideItsOwningWindow_AncestorClipDoesNotCutItOff() + { + var root = new TestElement { Width = 200f, Height = 200f }; + var window = new TestElement { Left = 10f, Top = 150f, Width = 80f, Height = 18f }; + var menu = new UiMenu + { + Width = 80f, + Height = 18f, + Items = new[] { new UiMenu.MenuItem("Row", (object?)null) }, + SpriteResolve = id => (id, 8, 8), + }; + root.AddChild(window); + window.AddChild(menu); + + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); + Assert.True(menu.IsOpen); + + // Not wrapped in BeginOverlayLayer/EndOverlayLayer (which UiRoot.DrawCore does + // in production, routing overlay draws to a SEPARATE buffer with no debug + // accessor) — the clip mechanism under test is layer-agnostic, so drawing to + // the normal buffer keeps DebugSpriteSegmentVerts usable here. + var (_, renderer, ctx) = MakeContext(200f, 200f); + root.DrawOverlays(ctx); + + // The popup opens upward (bottom touches the button's top, y=0), so its + // absolute screen top is window.Top(150) minus its own outer height — well + // above window.Top. Assert at least one popup quad renders strictly above the + // owning window's own top edge, i.e. outside window's [0,18] local rect. + bool escapedAboveWindow = AnyQuadAt(renderer, (_, y) => y < 150f - 0.5f); + Assert.True( + escapedAboveWindow, + "expected the open UiMenu popup to render above its owning window's top edge"); + } + + /// + /// Companion negative check: with the SAME geometry but the popup left CLOSED, no + /// quad renders above the window at all — proving the escape above is specifically + /// about the OPEN popup's content, not a blanket unclipped draw for the whole menu. + /// + [Fact] + public void UiMenuClosed_NothingRendersAboveItsOwningWindow() + { + var root = new TestElement { Width = 200f, Height = 200f }; + var window = new TestElement { Left = 10f, Top = 150f, Width = 80f, Height = 18f }; + var menu = new UiMenu + { + Width = 80f, + Height = 18f, + Items = new[] { new UiMenu.MenuItem("Row", (object?)null) }, + SpriteResolve = id => (id, 8, 8), + }; + root.AddChild(window); + window.AddChild(menu); + Assert.False(menu.IsOpen); + + // Not wrapped in BeginOverlayLayer/EndOverlayLayer (which UiRoot.DrawCore does + // in production, routing overlay draws to a SEPARATE buffer with no debug + // accessor) — the clip mechanism under test is layer-agnostic, so drawing to + // the normal buffer keeps DebugSpriteSegmentVerts usable here. + var (_, renderer, ctx) = MakeContext(200f, 200f); + root.DrawOverlays(ctx); + + Assert.False(AnyQuadAt(renderer, (_, y) => y < 150f - 0.5f)); + } + + /// + /// Retail's hover tooltip is the OTHER content this codebase draws "regardless of + /// tree position" (see 's class doc): it mounts + /// its popup as an ordinary CHILD (a sibling of every window), + /// not nested inside whatever widget triggered it — so unlike 's + /// popup, it needs no opt-out; it was + /// never inside the triggering window's ancestor-clip subtree to begin with. This + /// pins that structural invariant survives CT-GF1: hovering a target buried inside + /// a tiny (20x20) window still mounts and DRAWS the tooltip popup, unclipped by that + /// window's own bounds. + /// + [Fact] + public void RetailTooltip_StillRendersOutsideATinyAncestorWindow_BecauseItMountsAtRootLevel() + { + const uint popupRootId = 0x900u; + const uint textChildId = 0x901u; + const uint popupLayoutDid = 0x21000041u; + const uint popupBgSprite = 42u; + + ImportedLayout BuildPopup() + { + var rootInfo = new ElementInfo + { + Id = popupRootId, Type = 3, X = 0, Y = 0, Width = 30, Height = 30, + TooltipTextChildElementId = textChildId, + }; + rootInfo.StateMedia[""] = (popupBgSprite, 1); + var textInfo = new ElementInfo + { + Id = textChildId, Type = 12, X = 2, Y = 2, Width = 26, Height = 26, + }; + return LayoutImporter.BuildFromInfos( + rootInfo, new[] { textInfo }, id => (id, 8, 8), null); + } + + var root = new UiRoot { Width = 800f, Height = 600f }; + var presenter = new RetailTooltipPresenter(root, (_, _) => BuildPopup()); + + // A tiny "window" ancestor (20x20) hosting the hover target as a nested child. + // If the tooltip were drawn from INSIDE this subtree, CT-GF1's new default + // ancestor clip would cut it off — the popup's mouse-anchored position (32px + // offset per PositionAtMouse) lands well outside a 20x20 rect. + var window = new TestElement { Left = 5f, Top = 5f, Width = 20f, Height = 20f }; + var target = new TestElement + { + Left = 2f, Top = 2f, Width = 10f, Height = 10f, + AuthoredTooltipEnabled = true, + AuthoredTooltipText = "Rotate left.", + AuthoredTooltipRootElementId = popupRootId, + AuthoredTooltipLayoutDid = popupLayoutDid, + }; + window.AddChild(target); + root.AddChild(window); + + root.OnMouseMove(10, 10); // inside `target`, well inside the 20x20 window + root.Tick(0.016, 0); + root.Tick(0.016, root.TooltipDelayMs); + + // Mounted as a UiRoot SIBLING of `window`, not nested inside it. + UiElement popup = Assert.Single(root.Children, c => c != window); + Assert.Same(root, popup.Parent); + + var (_, renderer, ctx) = MakeContext(800f, 600f); + root.DrawSelfAndChildren(ctx); + + Assert.Contains(renderer.DebugSpriteSegmentVerts, s => s.Texture == popupBgSprite); + + presenter.Dispose(); + } +} From 025108a8aa6f2e0f4f5c749f4009139cddbf0695 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 07:11:42 +0200 Subject: [PATCH 57/89] =?UTF-8?q?fix(CT-GF1):=20review=20fix=20round=20?= =?UTF-8?q?=E2=80=94=20literal=20DrawHere=20clip=20shape,=20empty-clip=20c?= =?UTF-8?q?ull,=20popup=20input=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies all 11 items from the Opus dual-lens review of 989f6652 (0 blockers, 7 SHOULD-FIX, 4 NOTE): - S2: UiElement.DrawSelfAndChildren now pushes the ambient clip right after PushAlpha and wraps OnDraw + the children walk + OnDrawAfterChildren in ONE block — the literal UIRegion::DrawHere @0x0069FA30 shape, which clips an element's OWN DrawSelf too, not just its children (UIElement_Text::DrawSelf @0x00467AA0 locks glyph blits to its own clipped surface rect; UIRegion::DrawSelf @0x0069F1A0 blits per clip rect). Deleted the two now-redundant ad-hoc self-clips this supersedes: UiText.DrawText and UiField.DrawMultiLine both pushed their own (0,0,Width,Height) — exactly what the new ambient clip already provides one level up. Kept UiButton.DrawBlockLabel's clip: it clips to LabelBox/ValueBox, an authored INNER sub-rect that can be smaller than and offset from the button's own full rect — a genuine narrower viewport, not a redundant duplicate. - S3: deleted UiItemList's `ClipsChildren => CellWidth > 0f` override — correct under the old opt-in-false default, inverted under the new default-true (an unconfigured list would stop clipping instead of clipping like everything else). - S4: pinned the escaped-popup input path end to end. New UiAncestorClipTests test mounts a menu inside a short window on a real UiRoot, opens it, and proves a click in the escaped popup region reaches the menu through UiRoot.PopupHit (a plain top-down walk is proven to reject the same point first). UiRoot.WantsMouse now also checks PopupHit — it previously only checked Captured/ HitTestTopDown, so a game action could fire underneath an open dropdown's escaped region. OnMouseDown/OnScroll already routed through PopupHit first (#374); unchanged. - S5: strengthened the Titles-divider regression test's positive half. The old assertion only checked SOME quad's Y fell in a band — vacuously true given other same-band content. Now asserts the divider's exact rect (X and Y), then diffs against the same rect with the divider hidden (Visible=false) to prove the quad was actually attributable to it. - S1: added UiWindowDrawCaptureSweepTests — Character/Chat/Vendor/ Options mounted through their real production Bind entry points with a non-zero sprite resolver, drawn via RecordingGpuDevice, asserting a per-window vertex floor (~40-45% of this session's observed baseline: Character 588, Chat 162, Vendor 54, Options 240) plus one key sprite id read LIVE off the bound controller/element (never hardcoded). Character's key sprite (RetailChromeSprites. TopEdge) specifically exercises OnDrawAfterChildren, the exact path S2's caution note flagged. Inventory/Paperdoll/social/map-house skipped — no single fixture-driven top-level Bind entry point. - S6: added the CT-GF1 subsection to the campaign plan's ledger (989f6652 + this fix round; CT7 re-gate still owed). - S7: UiRenderContext.PushClipUnbounded now resets to the CANVAS rect (0,0,ScreenSize), not null — retail's own popup region is SCREEN-clipped (UIElement_Menu::MakePopup spawns a top-level region bounded by the screen), not truly unbounded. AD-113 amended. - N1: UiRoot overrides ClipsChildren => false — the root's own region IS the screen (the viewport already scissors it), so this is a safety net against a momentarily zero-sized root silently blanking the whole UI tree under the new ancestor-clip default. - N2: added the empty-clip subtree cull (retail's var_24 gate @0x0069FB8E) to DrawSelfAndChildren only — DrawOverlays is a wholly separate traversal untouched by this change. New test proves a menu inside a fully-clipped (zero-width) window still draws its open popup via the overlay pass while the main pass draws nothing. - N3: CT7 script §5 now names the collapsed-toolbar check and the four highest-overflow windows (combat/vitals bar, Options bottom-button row, map/house page, floaty chat) as explicit eyeball items for the re-gate. - N4: verification below covers both the working tree and the clean committed tree. Decomp anchors: UIRegion::DrawHere @0x0069FA30 (var_24 gate @0x0069FB8E); UIElement_Text::DrawSelf @0x00467AA0 (self-clip); UIRegion::DrawSelf @0x0069F1A0; UIElement_Menu::MakePopup (screen- clipped popup region). Verification (both runs green, --filter "Lane!=InstalledDat& Lane!=PreparedPackage&Lane!=Live&Lane!=Manual&Lane!=Timing& Lane!=Windows&Lane!=Linux&Lane!=SystemFont&Purpose!=Diagnostic& Status!=KnownFailure"): full Release solution build green; working tree 14,900+ tests across every project (one LandblockPresentation PipelineTests flake reproduced ONLY under full-solution parallel load, passes standalone and on rerun — unrelated to this change, streaming domain); InstalledDat lane green (ACDREAM_RUN_INSTALLED_DAT _TESTS=1, Status!=KnownFailure, 205+34+3+172 App/Content/Bake/Core tests). Clean committed tree (git stash push -u the uncommitted owner probe + docs files, rerun, stash pop) reported in the session summary. src/AcDream.App/UI/UiRoot.cs carries an unrelated, pre-existing uncommitted owner probe (ACDREAM_PROBE_UI_HOVER) — staged selectively (git add -p) so only this commit's own two hunks (ClipsChildren override, WantsMouse) landed; the probe hunk is untouched and stays uncommitted, same as before this fix round. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 2 +- ...6-08-24-character-panel-parity-campaign.md | 43 ++- .../2026-08-25-campaign-ct-test-script.md | 23 ++ src/AcDream.App/UI/UiButton.cs | 24 +- src/AcDream.App/UI/UiElement.cs | 101 ++++--- src/AcDream.App/UI/UiField.cs | 89 +++--- src/AcDream.App/UI/UiItemList.cs | 8 +- src/AcDream.App/UI/UiRenderContext.cs | 32 +- src/AcDream.App/UI/UiRoot.cs | 51 +++- src/AcDream.App/UI/UiText.cs | 22 +- .../Layout/CharacterTitlesControllerTests.cs | 58 +++- .../Layout/UiWindowDrawCaptureSweepTests.cs | 279 ++++++++++++++++++ .../UI/UiAncestorClipTests.cs | 105 +++++++ 13 files changed, 717 insertions(+), 120 deletions(-) create mode 100644 tests/AcDream.App.Tests/UI/Layout/UiWindowDrawCaptureSweepTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 1c99a8b6..a471ea5d 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -209,7 +209,7 @@ readiness/requeue adaptation. See | AD-100 | **Filed 2026-08-15 at the Campaign CC CC2 review, finding F2 (unrequested `0xF643` handling).** When a `0xF643` (`CharGenVerificationResponse`) arrives with NO outstanding create/restore request, acdream DROPS the message with a once-per-session stderr log. Retail has no such gate: `Handle_CharGenVerificationResponse @0x0055E8B0` processes whatever arrives, discriminating create-vs-restore by its OWN persistent verification state (case 1 branches on `GetVerificationState() == PENDING` → new `CharacterIdentity` + `AddIdentity`, else unpacks into the existing identity at `slot`) — an unsolicited reply would be applied against whatever that state happens to be. acdream's transport-level latch (`PendingCharGenVerificationRequest`) is the equivalent discriminator, but when it is `None` there is no state to apply the reply against, so the honest move is drop-and-log rather than guessing a family. | `src/AcDream.Core.Net/WorldSession.cs` (the `CharGenVerificationResponse.ResponseOpcode` arm in `ProcessDatagram`; `_loggedUnexpectedCharGenVerificationResponse`) | Processing an unsolicited reply requires retail's persistent chargen verification state, which lives in CC3's Runtime owner, not the transport. Until then a reply with no outstanding request is either a server bug or a latch-lifecycle bug on our side — surfacing it in the log beats silently misrouting it to an arbitrary event. Pinned by `WorldSessionCharacterCreationTests.ResponseWithNoOutstandingRequest_IsDroppedAndNeverMisattributed`. | A server that sends a spontaneous/duplicate `0xF643` (ACE can double-send NameInUse — see the CC2 review's F3 note) has its second copy dropped here, where retail would re-process it. If CC3's verification gate ever needs retail's re-process semantics, this drop must move behind that owner's state. | `Handle_CharGenVerificationResponse @0x0055E8B0`; `CharGenState::GetVerificationState`; CC2 review F2 (2026-08-15) | | AD-102 | **Filed 2026-08-15 at Campaign CC slice CC4 (the Heritage page's Viamontian button and the Town page's Sanamar button).** Retail gates BOTH controls behind `CPlayerSystem::AccountHasThroneOfDestiny`: `gmCGHeritagePage::ListenToElementMessage @ 0x00483860` shows `MakeToDWarningDialog` instead of selecting Viamontian (element `0x100003c3`) for a non-ToD account, and `gmCGTownPage::ListenToElementMessage @ 0x0047c480` does the same for Sanamar (element `0x1000040b`, `startArea` index 3 — also the reason `CharGenState::RandomizeStartArea`'s ToD-aware `RandInt(3 or 4)` bound exists). acdream's `ChargenOptions` (CC1) carries no account/DLC-ownership signal anywhere in the model, so both controls ship WITHOUT the gate — every installed heritage/town in `Options.HeritagesById`/`Options.StarterAreas` is always selectable, matching what a ToD-owning account would see. | `src/AcDream.App/UI/Layout/CharacterCreationHeritagePage.cs` (`HeritageByButtonId[0x100003C3u]`); `src/AcDream.App/UI/Layout/CharacterCreationTownPage.cs` (`StartAreaByButtonId[0x1000040Bu]`, `Randomize`) | ACE's server-side `CharacterCreate` handler never checks ToD ownership either (the field is purely a retail-client UI gate), so accepting the selection unconditionally never produces a request the emulator would reject; adding an account-ownership model to CC1's DAT-only `ChargenOptions` is out of this slice's scope and would need its own design (where does the "ToD owned" bit come from — account service, launcher config, a new env flag?). | None observable against ACE. A future retail-parity gate that specifically checks "does a non-ToD account get warned off Viamontian/Sanamar" will fail until an account-ownership signal exists to gate on. | `gmCGHeritagePage::ListenToElementMessage @ 0x00483860`; `gmCGTownPage::ListenToElementMessage @ 0x0047c480`; `gmCGTownPage::SetTown @ 0x0047c360`; `CharGenState::RandomizeStartArea` (DoRandom case 4, `RandInt(hasToD ? 4 : 3)`) | | AD-99 | **Filed 2026-08-15 at Campaign LA gate round 2 finding 1 (character-select Exit button).** On a confirmed Exit, acdream closes the client through the existing graceful window-close path (`d.Window.Close`, the same seam `GameplayInputCommandController`'s in-world Escape fallback already uses) instead of retail's real post-confirm behavior: `RecvNotice_CloseDialog`'s case-1 arm queues UI mode `0x10000009`, which `gmEpilogueUI::Register` claims — a brief epilogue/farewell screen — before the process actually terminates. The confirmation dialog itself (`MakeConfirmExitDialog`, its exact `ID_CharacterManagement_ConfirmExit` text, and the `m_confirmExitDialogContext != 0` re-entry guard) IS ported faithfully; only the post-confirm destination differs, the same shape as AD-74's Options-panel exit. | `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs` (`RequestExit`); `src/AcDream.App/UI/RetailUiRuntime.cs` (`CharacterSelectionRuntimeBindings.RequestExit`); `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs` (`d.Window.Close` binding) | acdream has no `gmEpilogueUI` port (out of scope this round); reusing the ONE existing graceful-shutdown seam keeps `disconnected`/`exited` status events firing through `GameWindow.OnClosing` → `CompleteShutdown` rather than inventing a second shutdown path, per explicit direction for this finding. | A user confirming Exit sees the window close immediately instead of retail's brief epilogue screen; a future feature wanting to reproduce that screen (or an intermediate "logged off, returned to character select" state) has no seam yet — same gap class as AD-44. | `gmCharacterManagementUI::MakeConfirmExitDialog @0x004ed250`; `RecvNotice_CloseDialog @0x004ed760` case 1; `gmEpilogueUI::Register(0x10000009)` @0x0047a680; `gmCharacterManagementUI::OnAction @0x004ed410` (Escape key, unported — button-only this round) | -| AD-113 | **Filed 2026-08-25 at Campaign CT slice CT-GF1 (client-wide retained-UI ancestor clip).** Porting retail's `UIRegion::DrawHere @0x0069FA30` ancestor-clip intersection (an element's screen rect is intersected against the FULL inherited clip-rect chain and the subtree is skipped when the intersection is empty — the `var_24` gate @0x0069FB8E) as `UiElement.ClipsChildren`'s new client-wide default (true, threaded through the pre-existing `UiRenderContext.PushClip`/`PopClip`) needed one deliberate opt-out: retail spawns a menu's dropdown popup as a SEPARATE top-level region (`UIElement_Menu::MakePopup`), clipped only by the screen, while acdream's `UiMenu` draws its popup INLINE from the owning button in a second traversal (`OnDrawOverlay`, pre-existing, "regardless of this element's position in the tree" by its own doc comment). Without an escape, the new ancestor clip would wrongly cut off a popup that legitimately extends outside its own (possibly short) owning window — e.g. a channel dropdown opened upward past a short chat window's top edge. `UiElement.ExpandsClipForPopup` (default false) resets the accumulated clip to unbounded for exactly the `OnDrawOverlay` call of an opted-in element (`UiRenderContext.PushClipUnbounded`, sharing the existing clip stack); `UiMenu` overrides it true, paired with `ClipsChildren => false` so its own out-of-bounds `OnHitTest` union (the popup occupies `ly < 0` or `ly >= Height` depending on open direction) stays reachable through the same early-bounds gate that now defaults on for every other element. | `src/AcDream.App/UI/UiElement.cs` (`ClipsChildren`, `ExpandsClipForPopup`, `DrawOverlays`); `src/AcDream.App/UI/UiRenderContext.cs` (`PushClipUnbounded`); `src/AcDream.App/UI/UiMenu.cs` (the two overrides) | The popup is the ONLY overlay-drawing widget in the tree today (grep-confirmed: exactly one `OnDrawOverlay` override client-wide), and it already renders on top of the whole UI by construction (the overlay pass beats even rect backgrounds), so exempting it from the ancestor clip matches its existing "regardless of tree position" contract rather than introducing new behavior. | A future `OnDrawOverlay` override that is NOT a screen-anchored popup (e.g. an in-place highlight meant to stay window-clipped) would silently escape every ancestor's clip if it left `ExpandsClipForPopup` at its default; the opt-in default direction makes that the exception rather than the rule, but a widget that WANTS window-clipped overlay content has no dedicated seam beyond simply not overriding the escape. | `UIRegion::DrawHere @0x0069FA30`; `UIElement_Menu::MakePopup`; the register's own AP-201 retirement note (the FIRST `ClipsChildren`/`PushClip` port, for `UiScrollablePanel`'s viewport) | +| AD-113 | **Filed 2026-08-25 at Campaign CT slice CT-GF1 (client-wide retained-UI ancestor clip).** Porting retail's `UIRegion::DrawHere @0x0069FA30` ancestor-clip intersection (an element's screen rect is intersected against the FULL inherited clip-rect chain and the subtree is skipped when the intersection is empty — the `var_24` gate @0x0069FB8E) as `UiElement.ClipsChildren`'s new client-wide default (true, threaded through the pre-existing `UiRenderContext.PushClip`/`PopClip`) needed one deliberate opt-out: retail spawns a menu's dropdown popup as a SEPARATE top-level region (`UIElement_Menu::MakePopup`), clipped only by the screen, while acdream's `UiMenu` draws its popup INLINE from the owning button in a second traversal (`OnDrawOverlay`, pre-existing, "regardless of this element's position in the tree" by its own doc comment). Without an escape, the new ancestor clip would wrongly cut off a popup that legitimately extends outside its own (possibly short) owning window — e.g. a channel dropdown opened upward past a short chat window's top edge. `UiElement.ExpandsClipForPopup` (default false) resets the accumulated clip to the full CANVAS rect (0,0,ScreenSize) — SCREEN-clipped, not truly unbounded, matching retail's own popup region (`UIElement_Menu::MakePopup` spawns a top-level region bounded by the screen) — for exactly the `OnDrawOverlay` call of an opted-in element (`UiRenderContext.PushClipUnbounded`, sharing the existing clip stack; corrected from an earlier `null`/unbounded clip at the CT-GF1 fix round); `UiMenu` overrides it true, paired with `ClipsChildren => false` so its own out-of-bounds `OnHitTest` union (the popup occupies `ly < 0` or `ly >= Height` depending on open direction) stays reachable through the same early-bounds gate that now defaults on for every other element. | `src/AcDream.App/UI/UiElement.cs` (`ClipsChildren`, `ExpandsClipForPopup`, `DrawOverlays`); `src/AcDream.App/UI/UiRenderContext.cs` (`PushClipUnbounded`); `src/AcDream.App/UI/UiMenu.cs` (the two overrides) | The popup is the ONLY overlay-drawing widget in the tree today (grep-confirmed: exactly one `OnDrawOverlay` override client-wide), and it already renders on top of the whole UI by construction (the overlay pass beats even rect backgrounds), so exempting it from the ancestor clip matches its existing "regardless of tree position" contract rather than introducing new behavior. | A future `OnDrawOverlay` override that is NOT a screen-anchored popup (e.g. an in-place highlight meant to stay window-clipped) would silently escape every ancestor's clip if it left `ExpandsClipForPopup` at its default; the opt-in default direction makes that the exception rather than the rule, but a widget that WANTS window-clipped overlay content has no dedicated seam beyond simply not overriding the escape. | `UIRegion::DrawHere @0x0069FA30`; `UIElement_Menu::MakePopup`; the register's own AP-201 retirement note (the FIRST `ClipsChildren`/`PushClip` port, for `UiScrollablePanel`'s viewport) | --- diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index 7310e3bd..c8a801d1 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -1,6 +1,6 @@ # Campaign CT — Character-panel retail parity (header identity, Titles page, resize/scrollbar, row alignment) -**Status:** IMPLEMENTATION COMPLETE 2026-08-25 — CT1-CT6 all review-closed (per-slice Opus dual-lens review + fix round); CT7 connected gate script ready at `docs/research/2026-08-25-campaign-ct-test-script.md`, awaiting the owner's drive. NOT pushed to gitea (owner directive). +**Status:** IMPLEMENTATION COMPLETE 2026-08-25 — CT1-CT6 all review-closed (per-slice Opus dual-lens review + fix round). CT-GF1 (the CT7 gate's own first finding — the client-wide retained-UI ancestor clip) landed `989f6652` and its fix round is CODE-COMPLETE (see the CT-GF1 subsection below); CT7 connected gate script ready at `docs/research/2026-08-25-campaign-ct-test-script.md`, still awaiting the owner's drive. NOT pushed to gitea (owner directive). **Execution model:** Fable plans and coordinates; Sonnet implements each slice; Opus runs the dual-lens review (retail-faithful + architectural) per slice, then a fix round. No pushes to gitea until the owner says so. @@ -547,6 +547,47 @@ cited (`0x10000180`, 300×362), not a new number. titles round trip against ACE (earn/set/display), header lines vs retail side-by-side, resize behavior, row alignment screenshots. +### CT-GF1 — client-wide retained-UI ancestor clip (gate finding + fix round) + +Landed `989f6652`: ports retail's `UIRegion::DrawHere @0x0069FA30` +ancestor-clip intersection as `UiElement.ClipsChildren`'s new client-wide +default (true), fixing the CT7 gate's own first finding — the Titles page's +authored divider `0x10000530` escaping the Character window above its top +edge at the CT6-correct 372px mounted default. One opt-out +(`UiElement.ExpandsClipForPopup`, `UiMenu`'s inline-drawn popup) plus new +`UiAncestorClipTests` mechanism coverage. + +**Fix round** (Opus dual-lens review, 0 blockers / 7 SHOULD-FIX / 4 NOTE, all +applied): moved the ambient clip to wrap `OnDraw` + children + +`OnDrawAfterChildren` in one block — the literal `DrawHere` shape, clipping +an element's own `DrawSelf` too, not just its children (`UIElement_Text:: +DrawSelf @0x00467AA0`; `UIRegion::DrawSelf @0x0069F1A0`) — and deleted the +two now-redundant ad-hoc self-clips it superseded (`UiText.DrawText`, +`UiField.DrawMultiLine`); kept the one that clips to a genuinely smaller +authored inner rect (`UiButton.DrawBlockLabel`'s `LabelBox`/`ValueBox`). +Deleted `UiItemList`'s `ClipsChildren` override (inverted under the new +default). Pinned the escaped-popup input path end to end (`UiRoot.PopupHit` +routing, `WantsMouse`) with a new real-`UiRoot` test. Strengthened the +Titles-divider regression test's positive half (exact-rect assertion + +visible/hidden diff, not a bare Y-band check). Added a draw-capture +regression sweep across Character/Chat/Vendor/Options mounted through their +real controllers (`UiWindowDrawCaptureSweepTests`). `PushClipUnbounded` now +resets to the screen rect, not `null` — retail's own popup region is +screen-clipped, not truly unbounded (AD-113 amended). `UiRoot.ClipsChildren` +now explicitly overrides false (the root's own region IS the screen — a +safety net against a momentarily zero-sized root blanking the whole UI). +Added the empty-clip subtree cull (retail's `var_24` gate), scoped to the +main draw pass only — the popup's separate `DrawOverlays` traversal is +provably unaffected (new coverage: a menu inside a fully-clipped window +still draws its popup). + +**Owed:** the CT7 re-gate (script `docs/research/2026-08-25-campaign-ct-test- +script.md`) still needs the owner's connected drive — this fix round landed +on the automated side only. §5 of that script now also names the +collapsed-toolbar check and the four highest-overflow windows (combat/ +vitals bar, Options bottom-button row, map/house page, floaty chat) as +explicit eyeball items for that same re-gate. + ## Review protocol Per slice: Sonnet implements → Opus dual-lens review (lens 1 diff --git a/docs/research/2026-08-25-campaign-ct-test-script.md b/docs/research/2026-08-25-campaign-ct-test-script.md index 3deadf1a..1f5094ba 100644 --- a/docs/research/2026-08-25-campaign-ct-test-script.md +++ b/docs/research/2026-08-25-campaign-ct-test-script.md @@ -142,6 +142,29 @@ only be dragged taller. PlayerDescription's values. - Chat window: the CH-round fixes hold (input rails on focus, "Gen" caption, button flick, no vibrating text while dragging). +- **CT-GF1 fix round (client-wide ancestor clip) — eyeball items.** The new + default clips every element to its own box by default; these are the + windows most likely to show a silent over-clip (content trimmed that + should be visible) if the port has an edge case the automated suite + didn't catch: + - **Collapsed toolbar**: collapse the combat/spell toolbar to its narrow + strip and back — confirm nothing inside it (icons, the collapse grip) + gets cut off or fails to reappear on expand. + - **Combat/vitals bar**: at its default size, confirm the health/ + stamina/mana bars and their numeric overlays render in full, not + trimmed at an edge. + - **Options panel bottom-button row** (Gameplay tab): confirm all seven + buttons (Exit to Character Selection, Configure Keyboard, In-Game + Help, Urgent Assistance, Report Abuse, mouse-turning checkbox, Exit + Game) render completely, none clipped at the panel's bottom edge. + - **Map/house page**: confirm the map image and player/house icons + render in full across the page's own scroll/zoom range, not clipped + at the viewport edge. + - **Floaty chat** (a detached floating chat window, Alt+1..4): confirm + the transcript and input row render in full at both a small and a + resized-larger window size — the same class of symptom CT-GF1's own + `ChatLayoutConformanceTests` regression-pinned for the main chat + window's input row. --- diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs index be2a82bf..0d4cc3d5 100644 --- a/src/AcDream.App/UI/UiButton.cs +++ b/src/AcDream.App/UI/UiButton.cs @@ -857,16 +857,20 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful text, font.MeasureWidth, font.LineHeight, boxX, boxY, boxWidth, boxHeight, align, leftOffset); - // A multi-line result (an authored '\n') clips to its own box — the - // button's normal draw has no ambient clip, and an oversized - // wrapped caption (e.g. the Skills credits button's own tight 28px - // height) should be cut off at the box edge rather than spill into - // whatever sits below the button, matching every other clipped - // Type-12 text box in this codebase (UiText.DrawText's own - // PushClip). Single-line captions — the overwhelming majority, - // and (post-R3-2) EVERY caption with no authored newline — never - // pay this cost; see this method's own doc for why a single line - // is deliberately left unclipped even when boxWidth was narrowed. + // A multi-line result (an authored '\n') clips to its own box — an oversized + // wrapped caption (e.g. the Skills credits button's own tight 28px height) + // should be cut off at the box edge rather than spill into whatever sits + // below the button. CT-GF1 fix round (S2) audit: this clip STAYS (unlike + // UiText.DrawText's and UiField.DrawMultiLine's now-deleted self-clips) — + // (boxX,boxY,boxWidth,boxHeight) is LabelBox/ValueBox, an authored INNER + // sub-rect that can be smaller than and offset from the button's own full + // (0,0,Width,Height) (see LabelBox's/ValueBox's own docs and the + // ValueBox-narrows-boxWidth branch above), so it is not redundant with the + // ambient (0,0,Width,Height) clip DrawSelfAndChildren now applies by default + // one level up. Single-line captions — the overwhelming majority, and + // (post-R3-2) EVERY caption with no authored newline — never pay this cost; + // see this method's own doc for why a single line is deliberately left + // unclipped even when boxWidth was narrowed. bool clip = lines.Count > 1; if (clip) ctx.PushClip(boxX, boxY, boxWidth, boxHeight); diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index 0002e869..7d95d961 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -544,30 +544,39 @@ public abstract class UiElement protected virtual void OnDrawOverlay(UiRenderContext ctx) { } /// - /// Whether descendant drawing and hit-testing are clipped to this element's - /// local bounds. THIS IS THE DEFAULT (true) FOR EVERY ELEMENT — CT-GF1 port - /// of retail's ancestor-clip chain: UIRegion::DrawHere @0x0069FA30 takes - /// the element's screen Box2D plus a SmartArray<Box2D> of - /// inherited clip rects, intersects them (the min/max clamp loop - /// @0x0069FAA7..0x0069FB82), and draws — EraseSelf/DrawChildren/ - /// DrawSelf all receive the intersected rect — ONLY when the intersection - /// is non-empty (the var_24 gate @0x0069FB8E). An element positioned - /// outside its parent's box therefore silently disappears in retail, exactly - /// like / - /// (already wrapping the child-draw and child-hit-test walks below) now does for - /// every element by default, not just the scrollable listboxes that opted in - /// before this default flipped (owner gate finding: the Titles page's authored - /// divider 0x10000530 escaped the Character window at the CT6-correct 372px - /// mounted default — retail clips it away; acdream drew it floating above the - /// window). + /// Whether THIS element's own draw AND its descendants' drawing/hit-testing are + /// clipped to this element's local bounds. THIS IS THE DEFAULT (true) FOR EVERY + /// ELEMENT — CT-GF1 port of retail's ancestor-clip chain: UIRegion::DrawHere + /// @0x0069FA30 takes the element's screen Box2D plus a + /// SmartArray<Box2D> of inherited clip rects, intersects them (the + /// min/max clamp loop @0x0069FAA7..0x0069FB82), and draws — EraseSelf/ + /// DrawChildren/DrawSelf ALL receive the intersected rect — ONLY + /// when the intersection is non-empty (the var_24 gate @0x0069FB8E). An + /// element positioned outside its parent's box therefore silently disappears in + /// retail, exactly like / + /// now does for every element by default — + /// 's fix-round shape (S2) pushes right after + /// PushAlpha and wraps OnDraw + the children walk + + /// OnDrawAfterChildren in ONE block, the literal DrawHere shape + /// (retail clips the element's OWN DrawSelf too, not just its children — + /// UIElement_Text::DrawSelf @0x00467AA0 locks glyph blits to its own + /// clipped surface rect; UIRegion::DrawSelf @0x0069F1A0 blits per clip + /// rect). Not just the scrollable listboxes that opted in before this default + /// flipped (owner gate finding: the Titles page's authored divider 0x10000530 + /// escaped the Character window at the CT6-correct 372px mounted default — + /// retail clips it away; acdream drew it floating above the window). /// /// /// Override to ONLY for a widget that must draw or accept /// input beyond its own bounds by deliberate design — today just - /// , whose popup (and its own out-of-bounds - /// OnHitTest override) stands in for retail's separate top-level popup - /// region; see for the drawing half of that - /// opt-out and the divergence register row it cites. + /// (whose popup, and its own out-of-bounds OnHitTest + /// override, stands in for retail's separate top-level popup region — see + /// for the drawing half of that opt-out and the + /// divergence register row it cites) and (whose own region + /// IS the screen — the viewport itself already scissors it, so narrowing to + /// (0,0,Width,Height) here would blank the whole UI the moment the root's + /// own tracked size is ever momentarily zero, e.g. before the first resize event + /// lands). /// /// protected virtual bool ClipsChildren => true; @@ -648,39 +657,49 @@ public abstract class UiElement // surface, chrome and glyphs together, not text-stays-sharp over a translucent panel). ctx.PushTransform(Left, Top); ctx.PushAlpha(Opacity); + // CT-GF1 fix round (S2): the clip now wraps OnDraw + children + + // OnDrawAfterChildren — the LITERAL UIRegion::DrawHere @0x0069FA30 shape, + // which clips the element's OWN DrawSelf to the intersected rect, not just its + // children (UIElement_Text::DrawSelf @0x00467AA0 locks glyph blits to arg3; + // UIRegion::DrawSelf @0x0069F1A0 blits per clip rect). Pushed right after + // PushAlpha, popped in the one finally below, so it is balanced regardless of + // which branch below runs. + bool clipsChildren = ClipsChildren; + if (clipsChildren) + ctx.PushClip(0f, 0f, Width, Height); try { - OnDraw(ctx); - - // Anchor layout: reflow children to this element's current size. - for (int i = 0; i < _children.Count; i++) - _children[i].ApplyAnchor(Width, Height); - - // Children painted back-to-front (lowest ZOrder first). - if (_children.Count > 0) + // N2 fix round: retail's var_24 gate @0x0069FB8E — an EMPTY intersected + // clip skips EraseSelf/DrawChildren/DrawSelf outright for the whole + // subtree. DrawOverlays (the popup's SEPARATE second traversal) does not + // share this walk or its clip-stack state, so an open UiMenu popup nested + // here keeps drawing there regardless of this cull — see + // UiAncestorClipTests' menu-inside-a-fully-clipped-window coverage. + if (!ctx.CurrentClipIsEmpty) { - bool clipsChildren = ClipsChildren; - if (clipsChildren) - ctx.PushClip(0f, 0f, Width, Height); - try + OnDraw(ctx); + + // Anchor layout: reflow children to this element's current size. + for (int i = 0; i < _children.Count; i++) + _children[i].ApplyAnchor(Width, Height); + + // Children painted back-to-front (lowest ZOrder first). + if (_children.Count > 0) { UiElement[] ordered = ChildrenBackToFrontSnapshot(); for (int i = 0; i < ordered.Length; i++) ordered[i].DrawSelfAndChildren(ctx); } - finally - { - if (clipsChildren) - ctx.PopClip(); - } - } - // Foreground pass for this element (e.g. a window frame's border drawn - // OVER its content's edges). Default no-op for ordinary elements. - OnDrawAfterChildren(ctx); + // Foreground pass for this element (e.g. a window frame's border drawn + // OVER its content's edges). Default no-op for ordinary elements. + OnDrawAfterChildren(ctx); + } } finally { + if (clipsChildren) + ctx.PopClip(); ctx.PopAlpha(); ctx.PopTransform(); } diff --git a/src/AcDream.App/UI/UiField.cs b/src/AcDream.App/UI/UiField.cs index b9686104..00ac7e2c 100644 --- a/src/AcDream.App/UI/UiField.cs +++ b/src/AcDream.App/UI/UiField.cs @@ -557,57 +557,56 @@ public sealed class UiField : UiElement Scroll.SetScrollY((int)MathF.Ceiling(caretBottom - visibleHeight)); } - ctx.PushClip(0f, 0f, Width, Height); - try + // CT-GF1 fix round (S2): this used to push its OWN (0,0,Width,Height) clip + // here. That is now REDUNDANT and deleted — UiElement.DrawSelfAndChildren + // (DrawMultiLine is called from OnDraw) wraps this whole method in exactly + // that same (0,0,Width,Height) ambient clip by default, matching retail's + // DrawHere shape one level up instead of duplicating it here. The lines below + // still rely on SOME clip being active (a partially visible row must still be + // rejected, not drawn full-size) — that clip is now the ambient one. + var (selectionLow, selectionHigh) = SelSpan(); + for (int i = 0; i < lines.Count; i++) { - var (selectionLow, selectionHigh) = SelSpan(); - for (int i = 0; i < lines.Count; i++) + WrappedLine line = lines[i]; + float y = Padding + (i * lineHeight) - Scroll.ScrollY; + if (y + lineHeight <= Padding || y >= Height - Padding) + continue; + + int lineEnd = line.Start + line.Length; + int highlightLow = Math.Max(selectionLow, line.Start); + int highlightHigh = Math.Min(selectionHigh, lineEnd); + if (HasSelection && highlightHigh > highlightLow) { - WrappedLine line = lines[i]; - float y = Padding + (i * lineHeight) - Scroll.ScrollY; - if (y + lineHeight <= Padding || y >= Height - Padding) - continue; - - int lineEnd = line.Start + line.Length; - int highlightLow = Math.Max(selectionLow, line.Start); - int highlightHigh = Math.Min(selectionHigh, lineEnd); - if (HasSelection && highlightHigh > highlightLow) - { - float x0 = Padding + MeasureRange( - line.Start, - highlightLow - line.Start); - float x1 = Padding + MeasureRange( - line.Start, - highlightHigh - line.Start); - ctx.DrawFill( - x0, - y, - MathF.Max(0f, x1 - x0), - lineHeight, - SelectionColor); - } - - if (DatFont is { } dat) - ctx.DrawStringDat(dat, line.Text, Padding, y, TextColor, Outline, OutlineColor); - else if (Font is { } bitmap) - ctx.DrawString(line.Text, Padding, y, TextColor, bitmap); + float x0 = Padding + MeasureRange( + line.Start, + highlightLow - line.Start); + float x1 = Padding + MeasureRange( + line.Start, + highlightHigh - line.Start); + ctx.DrawFill( + x0, + y, + MathF.Max(0f, x1 - x0), + lineHeight, + SelectionColor); } - if (_focused && lines.Count > 0) - { - WrappedLine line = lines[caretLine]; - int lineColumn = Math.Clamp( - _caret - line.Start, - 0, - line.Length); - float x = Padding + MeasureRange(line.Start, lineColumn); - float y = Padding + (caretLine * lineHeight) - Scroll.ScrollY; - ctx.DrawFill(x, y, 1f, lineHeight, TextColor); - } + if (DatFont is { } dat) + ctx.DrawStringDat(dat, line.Text, Padding, y, TextColor, Outline, OutlineColor); + else if (Font is { } bitmap) + ctx.DrawString(line.Text, Padding, y, TextColor, bitmap); } - finally + + if (_focused && lines.Count > 0) { - ctx.PopClip(); + WrappedLine line = lines[caretLine]; + int lineColumn = Math.Clamp( + _caret - line.Start, + 0, + line.Length); + float x = Padding + MeasureRange(line.Start, lineColumn); + float y = Padding + (caretLine * lineHeight) - Scroll.ScrollY; + ctx.DrawFill(x, y, 1f, lineHeight, TextColor); } } diff --git a/src/AcDream.App/UI/UiItemList.cs b/src/AcDream.App/UI/UiItemList.cs index d96b3654..2473b969 100644 --- a/src/AcDream.App/UI/UiItemList.cs +++ b/src/AcDream.App/UI/UiItemList.cs @@ -358,7 +358,13 @@ public sealed class UiItemList : UiElement } } - protected override bool ClipsChildren => CellWidth > 0f; + // CT-GF1 fix round (S3): the former `ClipsChildren => CellWidth > 0f` override is + // DELETED. It made sense under the PRE-CT-GF1 opt-in default (false): clip only + // once CellWidth is configured. Under the new client-wide default (true), that + // same expression is INVERTED — an unconfigured list (CellWidth<=0, e.g. before + // LayoutCells first runs) would evaluate to false and stop clipping, the opposite + // of every other element's new default. Deleting the override restores the + // uniform default (always clip to this element's own (0,0,Width,Height)). public void Flush() { diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs index 3697f7c9..a91ee7cb 100644 --- a/src/AcDream.App/UI/UiRenderContext.cs +++ b/src/AcDream.App/UI/UiRenderContext.cs @@ -116,18 +116,34 @@ public sealed class UiRenderContext } /// - /// Discard every inherited clip rect for the duration of one overlay draw — - /// the escape hatch uses so a popup - /// drawn inline from its owning widget (see that property's doc comment for the - /// retail-parity rationale) is not wrongly clipped by the ancestor chain the - /// CT-GF1 default clip () now threads through - /// every other element. Shares 's stack, so pair the two - /// exactly like . + /// True when the current accumulated clip is non-null and has zero (or negative) + /// area — CT-GF1 fix-round subtree cull, porting retail's + /// UIRegion::DrawHere var_24 gate @0x0069FB8E: an empty intersected + /// clip skips EraseSelf/DrawChildren/DrawSelf for the whole + /// subtree, not just individual draw calls (those already no-op against an empty + /// clip via / — this + /// additionally skips the WALK). A null clip (nothing pushed yet, or reset via + /// ) is NOT empty — it means unbounded, so this is + /// false in that case. + /// + public bool CurrentClipIsEmpty => _clip is { } c && c.IsEmpty; + + /// + /// Reset the accumulated clip to the full CANVAS rect (0,0,ScreenSize) for the + /// duration of one overlay draw — the escape hatch + /// uses so a popup drawn inline from its owning widget (see that property's doc + /// comment for the retail-parity rationale) is not wrongly clipped by the + /// ancestor chain the CT-GF1 default clip () + /// now threads through every other element. Retail's own popup region is still + /// SCREEN-clipped (UIElement_Menu::MakePopup spawns a top-level region + /// bounded by the screen, not truly infinite) — this is the canvas rect, not + /// null/unbounded, matching that. Shares 's stack, so + /// pair the two exactly like . /// public void PushClipUnbounded() { _clipStack.Add(_clip); - _clip = null; + _clip = new UiClipRect(0f, 0f, ScreenSize.X, ScreenSize.Y); } /// Route subsequent draws to the overlay layer (flushed on top of the whole diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index f22005eb..2ad658a0 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -33,6 +33,19 @@ public sealed class UiRoot : UiElement /// Single owner for named retained-window lifecycle and raise policy. public RetailWindowManager WindowManager { get; } + /// + /// CT-GF1 fix round (N1): the root's own region IS the screen — the viewport + /// itself already scissors everything drawn to it, so retail has no analog of + /// clipping the root to its OWN tracked (Width,Height) the way + /// 's new client-wide default would. + /// Overriding false here is a safety net, not a cosmetic choice: without it, a + /// root momentarily reporting a zero (or stale, pre-first-resize) size would + /// silently blank the ENTIRE UI tree — every top-level window culled by the new + /// ancestor-clip default's empty-intersection gate — rather than the intended + /// "root passes its full extent through to its children uninterpreted." + /// + protected override bool ClipsChildren => false; + /// /// Campaign LA gate round 2 (register AD-98): when set, the retained tree /// is laid out in this fixed authored canvas (the char-select screen's @@ -189,8 +202,27 @@ public sealed class UiRoot : UiElement /// The host ORs this into the InputDispatcher's WantCaptureMouse gate so game /// actions (movement, world-pick) are suppressed while the user interacts with /// a retail window — mirrors ImGui's WantCaptureMouse. + /// + /// + /// CT-GF1 fix round (S4): also checks — an open UiMenu + /// dropdown's escaped region (the part of the popup that extends outside its + /// owning window's own ancestor-clipped bounds, e.g. a channel menu opened + /// upward past a short chat window's top edge) is reachable by + /// / through + /// specifically BECAUSE it bypasses the ordinary top-down + /// walk's ancestor clip gate (CT-GF1's new client-wide + /// default would otherwise reject that + /// point before ever reaching the popup's own out-of-bounds OnHitTest + /// union — see #374's popup routing doc above). Without this, + /// alone would report "no widget here" for a point + /// the pointer visibly sits over, letting a world click/movement action slip + /// through underneath the open popup. + /// /// - public bool WantsMouse => Captured is not null || HitTestTopDown(MouseX, MouseY).element is not null; + public bool WantsMouse => + Captured is not null + || PopupHit(MouseX, MouseY) is not null + || HitTestTopDown(MouseX, MouseY).element is not null; /// True when a widget holds keyboard focus (e.g. a focused chat input). public bool WantsKeyboard => KeyboardFocus is not null; @@ -1386,6 +1418,23 @@ public sealed class UiRoot : UiElement Data1: (int)(x - screen.X), Data2: (int)(y - screen.Y)); w.OnEvent(in enter); + if (UiDiagnostics.ProbeHover) + { + string detail = w is UiScrollbar sb + ? $" start=0x{sb.ActiveStartSpriteForTest:X8}" + + $" end=0x{sb.ActiveEndSpriteForTest:X8}" + + $" thumb=0x{sb.ActiveThumbSpriteForTest:X8}" + + $" disabled={sb.IsModelDisabled}" + : ""; + Console.WriteLine( + $"[ui-hover] widget={w.GetType().Name}" + + $" dat=0x{w.DatElementId:X8}" + + $" local=({(int)(x - screen.X)},{(int)(y - screen.Y)}){detail}"); + } + } + else if (UiDiagnostics.ProbeHover) + { + Console.WriteLine("[ui-hover] widget="); } } diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 0c8ce38c..71fc7202 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -584,17 +584,17 @@ public sealed class UiText : UiElement, IUiDatStateful // visible surface as arg3 and clips each glyph blit to that rectangle. This is // observable in LayoutDesc 0x21000033: the owned component count is a 15px-high // text element using a 16px DAT font, so rejecting a partially visible line makes - // the value disappear entirely. The shared render context clips both DAT and - // bitmap glyph quads and composes this bound with any list/window ancestor clip. - ctx.PushClip(0f, 0f, Width, Height); - try - { - DrawClippedText(ctx); - } - finally - { - ctx.PopClip(); - } + // the value disappear entirely. + // + // CT-GF1 fix round (S2): this used to push its OWN (0,0,Width,Height) clip + // here. That is now REDUNDANT and deleted — UiElement.DrawSelfAndChildren + // wraps OnDraw/OnDrawAfterChildren (where DrawText is called from) in exactly + // that same (0,0,Width,Height) ambient clip by default, intersected with any + // list/window ancestor clip, matching retail's DrawHere shape one level up + // instead of duplicating it here. DrawClippedText still relies on SOME clip + // being active for correctness (a partially visible line must still be + // rejected, not drawn full-size) — that clip is now the ambient one. + DrawClippedText(ctx); } private void DrawClippedText(UiRenderContext ctx) diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs index 27424699..8fa77860 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterTitlesControllerTests.cs @@ -721,7 +721,24 @@ public sealed class CharacterTitlesControllerTests dividerGrown.Y >= 0f && dividerGrown.Y + divider.Height <= 600f, "expected the Titles divider to land inside the grown window at its authored " + $"spot; got {dividerGrown.Y}"); - AssertQuadCoversY(renderer, dividerGrown.Y, dividerGrown.Y + divider.Height); + + // CT-GF1 fix round (S5): AssertQuadCoversY alone only proves SOMETHING drew + // in that Y band -- not that it was specifically THIS divider's own quad (the + // grown Titles page has other rows/backgrounds that could coincidentally + // share the band). Strengthen both halves: pin the divider's exact rect (X + // AND Y range, not Y alone), THEN diff against the same rect with the + // divider hidden -- if hiding it does not empty the rect out, whatever drew + // there was never uniquely attributable to the divider in the first place. + AssertQuadCoversRect( + renderer, dividerGrown.X, dividerGrown.Y, + dividerGrown.X + divider.Width, dividerGrown.Y + divider.Height); + + divider.Visible = false; + renderer.Begin(new Vector2(screen.Width, screen.Height)); + handle.OuterFrame.DrawSelfAndChildren(ctx); + AssertNoQuadCoversRect( + renderer, dividerGrown.X, dividerGrown.Y, + dividerGrown.X + divider.Width, dividerGrown.Y + divider.Height); } private sealed class NullGpuFrameSource : ICurrentGpuFrameSource @@ -744,6 +761,45 @@ public sealed class CharacterTitlesControllerTests } } + /// CT-GF1 fix round (S5): the X-and-Y-range companion to + /// — a passing vertex must land inside BOTH + /// axes' rect, not just the Y band, so an unrelated same-band element (a + /// background, another row) cannot satisfy this vacuously. + private static void AssertQuadCoversRect(TextRenderer renderer, float xLo, float yLo, float xHi, float yHi) + { + bool found = renderer.DebugSpriteSegmentVerts.Any(seg => + { + for (int i = 0; i < seg.Verts.Count / 8; i++) + { + float vx = seg.Verts[i * 8]; + float vy = seg.Verts[i * 8 + 1]; + if (vx >= xLo - 0.5f && vx <= xHi + 0.5f && vy >= yLo - 0.5f && vy <= yHi + 0.5f) + return true; + } + return false; + }); + Assert.True(found, $"expected at least one quad vertex inside rect [{xLo},{yLo}]..[{xHi},{yHi}]"); + } + + /// CT-GF1 fix round (S5): the negative half of — + /// used after hiding the divider to prove the earlier positive assertion was + /// attributable to it specifically (a diff, not a coincidence). + private static void AssertNoQuadCoversRect(TextRenderer renderer, float xLo, float yLo, float xHi, float yHi) + { + foreach (var seg in renderer.DebugSpriteSegmentVerts) + { + for (int i = 0; i < seg.Verts.Count / 8; i++) + { + float vx = seg.Verts[i * 8]; + float vy = seg.Verts[i * 8 + 1]; + Assert.False( + vx >= xLo - 0.5f && vx <= xHi + 0.5f && vy >= yLo - 0.5f && vy <= yHi + 0.5f, + $"unexpected quad vertex at ({vx},{vy}) inside the divider's own rect " + + $"[{xLo},{yLo}]..[{xHi},{yHi}] after hiding it"); + } + } + } + private static void AssertQuadCoversY(TextRenderer renderer, float yLo, float yHi) { bool found = renderer.DebugSpriteSegmentVerts.Any(seg => diff --git a/tests/AcDream.App.Tests/UI/Layout/UiWindowDrawCaptureSweepTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiWindowDrawCaptureSweepTests.cs new file mode 100644 index 00000000..e4afb918 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/UiWindowDrawCaptureSweepTests.cs @@ -0,0 +1,279 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Tests.Rendering.Gpu; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.Chat; +using AcDream.Core.Items; +using AcDream.Core.Properties; +using AcDream.Core.Selection; +using AcDream.Runtime.Gameplay; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// CT-GF1 fix round (S1): a draw-capture regression sweep mounting each major +/// retained window through its REAL controller (the same production Bind +/// entry points other suites already exercise individually) with a NON-ZERO +/// sprite resolver, drawing through a , and +/// asserting a per-window VERTEX-COUNT FLOOR plus the presence of one KEY +/// sprite id. Every key sprite id is read LIVE off the bound +/// controller/element after Bind — never a hardcoded numeric guess (this +/// project's workflow forbids guessing dat ids) — so the assertion tracks +/// whatever the real import/bind pipeline actually resolved, not an +/// assumption about it. +/// +/// This is the class of coverage the CH6a/b BLOCKER 1 bug slipped past: an +/// authored non-zero sprite id sitting right there on the ElementInfo, with +/// nothing actually reaching DrawSprite because the widget was built +/// without its resolve delegate. A structural/geometry test (FindElement, +/// property checks) cannot see that class of regression; only an actual draw +/// pass through a real render context can. +/// +/// +/// Windows covered: Character (this is ALSO CT-GF1's own motivating +/// case — its chrome draws via +/// , the exact code path +/// the S2/item-1 fix-round caution note calls out as needing re-proof after +/// moving the ambient clip to wrap it), Chat (Imported chrome, real +/// committed fixture), Vendor (Imported chrome, real committed +/// fixture), Options (the 4-tab panel host — no +/// wrapper; mounts as a bare +/// , matching OptionsPanelControllerTests's +/// own established pattern). SKIPPED for lack of a reusable, fixture-driven, +/// SINGLE top-level Bind entry point at the time of writing: Inventory/ +/// Paperdoll (composed from several independent controllers, no single +/// window-level Bind), the social panel and the map/house host (their own +/// mount probes are Lane=Manual, live-DAT-only — see +/// SocialPanelLiveMountProbeTests/MapHousePanelSlotProbeTests). +/// +/// +/// +/// Floors are set at roughly 40-45% of this session's OBSERVED vertex count +/// per window (Character 588, Chat 162, Vendor 54, Options 240 — see each +/// Build* method's own trailing comment) — per the fix-round +/// instruction, loose enough to survive legitimate content growth/shrinkage, +/// tight enough to still catch "half (or all) of this window stopped +/// drawing" (a resolve-wiring regression), not exact counts. +/// +/// +public sealed class UiWindowDrawCaptureSweepTests +{ + private sealed class NullGpuFrameSource : ICurrentGpuFrameSource + { + public IGpuFrame? CurrentFrame => null; + } + + private static (RecordingGpuDevice device, TextRenderer renderer, UiRenderContext ctx) MakeContext( + float w, float h) + { + var device = new RecordingGpuDevice(); + var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused"); + renderer.Begin(new Vector2(w, h)); + var ctx = new UiRenderContext(renderer, new Vector2(w, h)); + return (device, renderer, ctx); + } + + /// Total vertex count across every recorded sprite segment — the + /// same per-segment Verts.Count / 8 convention every other test in + /// this suite uses (8 floats packed per vertex). + private static int TotalVertexCount(TextRenderer renderer) + { + int total = 0; + foreach (var seg in renderer.DebugSpriteSegmentVerts) + total += seg.Verts.Count / 8; + return total; + } + + public static IEnumerable Windows() + { + yield return new object[] { "Character" }; + yield return new object[] { "Chat" }; + yield return new object[] { "Vendor" }; + yield return new object[] { "Options" }; + } + + [Theory] + [MemberData(nameof(Windows))] + public void MountedWindow_DrawsAVertexFloor_AndItsLiveKeySpriteId(string window) + { + (UiElement drawRoot, uint keySprite, int vertexFloor) = window switch + { + "Character" => BuildCharacter(), + "Chat" => BuildChat(), + "Vendor" => BuildVendor(), + "Options" => BuildOptions(), + _ => throw new ArgumentOutOfRangeException(nameof(window), window, null), + }; + + Assert.NotEqual(0u, keySprite); + + var (_, renderer, ctx) = MakeContext(1600f, 1200f); + drawRoot.DrawSelfAndChildren(ctx); + + int vertices = TotalVertexCount(renderer); + Assert.True( + vertices >= vertexFloor, + $"{window}: expected at least {vertexFloor} drawn vertices, got {vertices} " + + "-- a resolve-wiring regression (CH6a/b BLOCKER 1's class) would show up as a " + + "near-zero count here."); + Assert.Contains( + renderer.DebugSpriteSegmentVerts, + s => s.Texture == keySprite); + } + + private static (UiElement drawRoot, uint keySprite, int vertexFloor) BuildCharacter() + { + ImportedLayout layout = LayoutImporter.Build( + FixtureLoader.LoadCharacterInfos(), id => (id, 8, 8), null); + CharacterStatController.Bind( + layout, SampleData.SampleCharacter, spriteResolve: id => (id, 8, 8)); + + var screen = new UiRoot { Width = 1600f, Height = 1200f }; + RetailWindowHandle handle = RetailWindowFrame.Mount( + screen, + layout.Root, + id => (id, 8, 8), + new RetailWindowFrame.Options + { + WindowName = WindowNames.Character, + Chrome = RetailWindowChrome.NineSlice, + ContentHeight = 362f, + MinWidth = 310f, + MaxWidth = 310f, + MinHeight = 372f, + MaxHeight = 1000f, + ResizeX = false, + ResizeY = true, + ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom, + }); + + // Key sprite: RetailChromeSprites.TopEdge -- drawn by + // UiNineSlicePanel.OnDrawAfterChildren, the exact code path the S2/ + // item-1 fix-round caution note calls out. Not read "live" (it's a + // shared named constant, not a per-window authored id), but it is + // NOT a guess either -- it is the actual constant the chrome drawer + // uses, verified by reading UiNineSlicePanel.OnDrawAfterChildren. + return (handle.OuterFrame, RetailChromeSprites.TopEdge, 250); // observed baseline 588 + } + + private static (UiElement drawRoot, uint keySprite, int vertexFloor) BuildChat() + { + var infos = FixtureLoader.LoadChatInfos(); + ImportedLayout layout = LayoutImporter.Build(infos, id => (id, 8, 8), null); + var controller = ChatWindowController.Bind( + infos, + layout, + new ChatVM(new ChatLog()), + () => NullCommandBus.Instance, + new ChatWindowState(), + null, + null, + id => (id, 8, 8)); + Assert.NotNull(controller); + + var root = new UiRoot { Width = 1600f, Height = 1200f }; + RetailWindowHandle handle = RetailWindowFrame.Mount( + root, + controller!.Root, + id => (id, 8, 8), + new RetailWindowFrame.Options + { + WindowName = WindowNames.Chat, + Chrome = RetailWindowChrome.Imported, + Left = 10f, + Top = 10f, + DatConstraintSource = controller.DatWindowInfo, + }); + controller.AttachWindow(handle); + + // Key sprite: read LIVE off the bound scrollbar's own TrackSprite -- + // the scrollbar always draws its track whenever the window does, so + // this tracks whatever the real fixture actually authors rather than + // a hardcoded literal. + return (handle.OuterFrame, controller.Scrollbar.TrackSprite, 80); // observed baseline 162 + } + + private static (UiElement drawRoot, uint keySprite, int vertexFloor) BuildVendor() + { + ImportedLayout layout = FixtureLoader.LoadVendor(); + var screen = new UiRoot { Width = 1600f, Height = 1200f }; + RetailWindowHandle window = RetailWindowFrame.Mount( + screen, + layout.Root, + id => (id, 8, 8), + new RetailWindowFrame.Options + { + WindowName = "vendor-sweep", + Chrome = RetailWindowChrome.Imported, + Visible = true, + }); + + var objects = new ClientObjectTable(); + var itemInteraction = new ItemInteractionController( + objects, + new RuntimeInteractionTransactionState(new InventoryTransactionState(objects)), + new InteractionState(), + playerGuid: static () => 0u, + sendUse: null, + sendUseWithTarget: null, + sendWield: null, + sendDrop: null); + VendorUiController? controller = VendorUiController.Bind( + layout, + new VendorState(), + window, + static (_, iconId, _, _, _) => iconId, + objects, + static () => 0u, + itemInteraction, + new SelectionState(), + new StackSplitQuantityState(), + datFont: null, + debugFont: null, + id => (id, 8, 8)); + Assert.NotNull(controller); + + // Key sprite: read LIVE off the bound category-filter menu's own + // button-face sprite (0x100000BF, VendorUiController.TypeFilterMenuId) -- + // its face always draws whenever the window does, so this tracks + // whatever the real committed vendor fixture actually authors. + var typeMenu = Assert.IsType(layout.FindElement(VendorUiController.TypeFilterMenuId)); + return (window.OuterFrame, typeMenu.NormalSprite, 25); // observed baseline 54 + } + + private static (UiElement drawRoot, uint keySprite, int vertexFloor) BuildOptions() + { + // NOTE: FixtureLoader.LoadOptionsPanelHost() (the convenience wrapper) bakes + // in FixtureLoader's own NULL-returning sprite resolver at LayoutImporter.Build + // time -- permanent for every widget it builds, unaffected by whatever resolver + // is later passed into OptionsPanelController.Bind (which only reaches content + // that controller creates itself, e.g. the per-page footer backing). Building + // from the raw ElementInfo tree here (matching BuildCharacter/BuildChat/ + // BuildVendor's own pattern above) is what actually gets a non-zero resolver + // onto the imported header/tab/page widgets themselves. + ImportedLayout layout = LayoutImporter.Build( + FixtureLoader.LoadOptionsPanelHostInfos(), id => (id, 8, 8), null); + var callbacks = new OptionsPanelController.Callbacks( + Toggle: () => { }, + RequestExitToCharacterSelection: () => { }, + ExitGame: () => { }, + UseMouseTurningSettings: () => { }, + DisplaySystemMessage: _ => { }); + OptionsPanelController? controller = OptionsPanelController.Bind( + layout, callbacks, resolveSprite: id => (id, 8, 8)); + Assert.NotNull(controller); + controller!.ActivateTabs(); + + // Key sprite: RetailChromeSprites.CenterFill -- the exact id + // OptionsPanelControllerTests' own CollectFooterBackings helper pins + // as every page's footer backing (a UiSolidSpriteFill), read here as + // the same shared named constant, not a guess. + return (layout.Root, RetailChromeSprites.CenterFill, 120); // observed baseline 240 + } +} diff --git a/tests/AcDream.App.Tests/UI/UiAncestorClipTests.cs b/tests/AcDream.App.Tests/UI/UiAncestorClipTests.cs index e240990c..22f976f8 100644 --- a/tests/AcDream.App.Tests/UI/UiAncestorClipTests.cs +++ b/tests/AcDream.App.Tests/UI/UiAncestorClipTests.cs @@ -271,4 +271,109 @@ public sealed class UiAncestorClipTests presenter.Dispose(); } + + /// + /// CT-GF1 fix round (S4): pins the input half of the popup-escape mechanism the + /// draw-only tests above only cover visually. A menu mounted inside a SHORT owning + /// window on a REAL , opened, has its popup's first row land + /// well ABOVE the window's own [0,Height) local rect — the escaped region. An + /// ordinary top-down walk would reject a point + /// there before ever reaching the menu: the owning `window`'s own + /// default (true, CT-GF1) rejects any + /// out-of-bounds local coordinate in BEFORE + /// recursing into its children, so the menu's own out-of-bounds + /// OnHitTest union is never consulted. UiRoot's PopupHit + /// routing (#374) is what rescues this: while a popup is registered active, a + /// press/scroll/ query is tested directly against + /// the popup element itself, bypassing the ancestor walk entirely. + /// + [Fact] + public void EscapedPopupClick_ReachesTheMenu_ThroughAShortOwningWindow() + { + var root = new UiRoot { Width = 200f, Height = 200f }; + var window = new TestElement { Left = 10f, Top = 150f, Width = 80f, Height = 18f }; + string? picked = null; + var menu = new UiMenu + { + Width = 80f, + Height = 18f, + OpenUpward = true, + RowsPerColumn = 1, // one row -> a small, exactly-known popup rect + Items = new[] { new UiMenu.MenuItem("Row", (object?)"row") }, + SpriteResolve = id => (id, 8, 8), + }; + menu.OnSelect = p => picked = p as string; + window.AddChild(menu); + root.AddChild(window); + + // Open the popup via a REAL click on the button face (screen space). + root.OnMouseDown(UiMouseButton.Left, 20, 155); + root.OnMouseUp(UiMouseButton.Left, 20, 155); + Assert.True(menu.IsOpen); + + // OuterW = ColumnWidth(191) + 2*Border(5) = 201; OuterH = 1*RowHeight(17) + + // 2*Border(5) = 27. Opens upward from the button's own screen top (150), so + // the popup spans screen Y = 150-27=123 .. 150 -- strictly above the owning + // window's own [150,168) rect, i.e. the escaped region. + const int rowScreenY = 135; // inside [123,150) + const int rowScreenX = 60; // inside [10,211) + Assert.True(rowScreenY < 150, "sanity: the row must sit above the window's own top edge"); + + // Without PopupHit, a plain top-down walk at this point would be rejected by + // `window`'s own ancestor-clip bounds check before ever reaching the menu -- + // proven directly against the SAME tree/geometry, no popup registered. + Assert.Null(root.Pick(rowScreenX, rowScreenY)); + + // WantsMouse must recognize the escaped popup region too (S4), so a game + // action does not fire underneath an open dropdown. + root.OnMouseMove(rowScreenX, rowScreenY); + Assert.True(root.WantsMouse, "WantsMouse must see the escaped popup through PopupHit"); + + root.OnMouseDown(UiMouseButton.Left, rowScreenX, rowScreenY); + root.OnMouseUp(UiMouseButton.Left, rowScreenX, rowScreenY); + + Assert.Equal("row", picked); + Assert.False(menu.IsOpen); + } + + /// + /// CT-GF1 fix round (N2): the empty-clip subtree cull added to + /// (retail's var_24 gate, + /// UIRegion::DrawHere @0x0069FB8E) early-outs once the intersected clip + /// goes empty -- e.g. a window whose own Width has collapsed to zero. + /// is a wholly SEPARATE traversal (the + /// second pass runs after the main one) that shares no + /// clip-stack state with the cull above, so an open popup + /// nested inside such a window must keep drawing there regardless. + /// + [Fact] + public void UiMenuPopup_StillDraws_EvenWhenItsOwningWindowIsFullyClippedAway() + { + var root = new TestElement { Width = 200f, Height = 200f }; + var window = new TestElement { Left = 10f, Top = 150f, Width = 0f, Height = 18f }; + var menu = new UiMenu + { + Width = 80f, + Height = 18f, + Items = new[] { new UiMenu.MenuItem("Row", (object?)null) }, + SpriteResolve = id => (id, 8, 8), + }; + root.AddChild(window); + window.AddChild(menu); + + Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); + Assert.True(menu.IsOpen); + + var (_, renderer, ctx) = MakeContext(200f, 200f); + + // Main pass: `window`'s own zero-width clip is empty -- the cull skips its + // whole subtree (including the menu's own button face), so nothing draws. + root.DrawSelfAndChildren(ctx); + Assert.Empty(renderer.DebugSpriteSegmentVerts); + + // Overlay pass: the SAME open popup still renders -- proves the cull above is + // scoped to DrawSelfAndChildren and never reaches DrawOverlays. + root.DrawOverlays(ctx); + Assert.NotEmpty(renderer.DebugSpriteSegmentVerts); + } } From 90a0da682017ca8cfc2840df0c57009ffecebf9b Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 07:13:37 +0200 Subject: [PATCH 58/89] =?UTF-8?q?fix:=20correct=20025108a8=20=E2=80=94=20d?= =?UTF-8?q?rop=20the=20accidentally-committed=20owner=20probe=20hunk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 025108a8's own git-add -p staging for src/AcDream.App/UI/UiRoot.cs was correct (only the ClipsChildren override + WantsMouse hunks staged, verified via git diff --cached before committing), but the trailing `git commit -m ... -- ` listed UiRoot.cs by path — and a pathspec-scoped `git commit` re-reads THOSE paths from the WORKING TREE rather than honoring the index, silently pulling in the pre-existing uncommitted ACDREAM_PROBE_UI_HOVER hunk alongside the two intended ones. This commit removes exactly that 17-line probe hunk from HEAD via a direct index/blob edit (git hash-object + update-index), touching ONLY the git object database — the working tree file is untouched and still carries the probe as an uncommitted change, exactly as it was before the CT-GF1 fix round started. Diffed the corrected blob against HEAD to confirm the removal is byte-for-byte just the probe block, nothing else. Co-Authored-By: Claude Fable 5 --- src/AcDream.App/UI/UiRoot.cs | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 2ad658a0..be9e107f 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -1418,23 +1418,6 @@ public sealed class UiRoot : UiElement Data1: (int)(x - screen.X), Data2: (int)(y - screen.Y)); w.OnEvent(in enter); - if (UiDiagnostics.ProbeHover) - { - string detail = w is UiScrollbar sb - ? $" start=0x{sb.ActiveStartSpriteForTest:X8}" - + $" end=0x{sb.ActiveEndSpriteForTest:X8}" - + $" thumb=0x{sb.ActiveThumbSpriteForTest:X8}" - + $" disabled={sb.IsModelDisabled}" - : ""; - Console.WriteLine( - $"[ui-hover] widget={w.GetType().Name}" - + $" dat=0x{w.DatElementId:X8}" - + $" local=({(int)(x - screen.X)},{(int)(y - screen.Y)}){detail}"); - } - } - else if (UiDiagnostics.ProbeHover) - { - Console.WriteLine("[ui-hover] widget="); } } From 9e85d82325bb9fc565550670abd154a4c364830b Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 07:17:12 +0200 Subject: [PATCH 59/89] docs(CT): CT-GF1 review-closed Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-24-character-panel-parity-campaign.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index c8a801d1..87afc896 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -1,6 +1,6 @@ # Campaign CT — Character-panel retail parity (header identity, Titles page, resize/scrollbar, row alignment) -**Status:** IMPLEMENTATION COMPLETE 2026-08-25 — CT1-CT6 all review-closed (per-slice Opus dual-lens review + fix round). CT-GF1 (the CT7 gate's own first finding — the client-wide retained-UI ancestor clip) landed `989f6652` and its fix round is CODE-COMPLETE (see the CT-GF1 subsection below); CT7 connected gate script ready at `docs/research/2026-08-25-campaign-ct-test-script.md`, still awaiting the owner's drive. NOT pushed to gitea (owner directive). +**Status:** IMPLEMENTATION COMPLETE 2026-08-25 (+ gate-fix CT-GF1 review-closed: retail ancestor+self clip `989f6652`/`025108a8`/`90a0da68`) — CT1-CT6 all review-closed (per-slice Opus dual-lens review + fix round). CT-GF1 (the CT7 gate's own first finding — the client-wide retained-UI ancestor clip) landed `989f6652` and its fix round is CODE-COMPLETE (see the CT-GF1 subsection below); CT7 connected gate script ready at `docs/research/2026-08-25-campaign-ct-test-script.md`, still awaiting the owner's drive. NOT pushed to gitea (owner directive). **Execution model:** Fable plans and coordinates; Sonnet implements each slice; Opus runs the dual-lens review (retail-faithful + architectural) per slice, then a fix round. No pushes to gitea until the owner says so. From 752782d0a9ea78521c270767e45adea2931618ec Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 07:28:26 +0200 Subject: [PATCH 60/89] =?UTF-8?q?fix(CT-GF1):=20UiLabel=20opts=20out=20of?= =?UTF-8?q?=20the=20self-clip=20=E2=80=94=20plugin=20markup=20text=20resto?= =?UTF-8?q?red?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CT7 gate regression (owner report): all MossTank plugin text vanished except button captions. A markup private static string? PkStatusText(uint publicWeenieBitfield, Func? resolveUiString) { - string key = (publicWeenieBitfield & PkPwdBit) != 0u + var bitfield = (PublicWeenieFlags)publicWeenieBitfield; + string key = (bitfield & PublicWeenieFlags.PlayerKiller) != 0 ? "ID_StatManagement_Header_PKStatus_PK" - : (publicWeenieBitfield & PkLitePwdBit) != 0u + : (bitfield & PublicWeenieFlags.PlayerKillerLite) != 0 ? "ID_StatManagement_Header_PKStatus_PKL" : "ID_StatManagement_Header_PKStatus_NPK"; return resolveUiString?.Invoke(key); } - /// PWD bit 5 — ACCWeenieObject::IsPK @0x0058c8b0: - /// (bitfield >> 5) & 1. - private const uint PkPwdBit = 0x20u; - - /// PWD bit 0x19 (25) — ACCWeenieObject::IsPKLite @0x0058c8a0: - /// (bitfield >> 0x19) & 1. - private const uint PkLitePwdBit = 0x02000000u; - /// Unenchanted base attribute value (Ranks + Start). Used for /// — the retail /// footer-title delta parenthetical compares this against diff --git a/src/AcDream.Core/Items/ItemInteractionPolicy.cs b/src/AcDream.Core/Items/ItemInteractionPolicy.cs index f1bc7210..2eb3d4bc 100644 --- a/src/AcDream.Core/Items/ItemInteractionPolicy.cs +++ b/src/AcDream.Core/Items/ItemInteractionPolicy.cs @@ -18,6 +18,8 @@ public enum PublicWeenieFlags : uint Stuck = 0x00000004, Player = 0x00000008, Attackable = 0x00000010, + /// PWD bit 5 — ACCWeenieObject::IsPK @0x0058C8B0. + PlayerKiller = 0x00000020, Vendor = 0x00000200, PlayerKillerSwitch = 0x00000400, NonPlayerKillerSwitch = 0x00000800, @@ -33,6 +35,8 @@ public enum PublicWeenieFlags : uint /// . /// Retained = 0x01000000, + /// PWD bit 0x19 (25) — ACCWeenieObject::IsPKLite @0x0058C8A0. + PlayerKillerLite = 0x02000000, VolatileRare = 0x10000000, WieldOnUse = 0x20000000, WieldLeft = 0x40000000, diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs index aaeb3d56..94b523bd 100644 --- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs @@ -759,6 +759,49 @@ public sealed class AppraisalUiControllerTests Assert.Equal(string.Empty, HeaderText(layout, 0x1000053Au)); } + [Fact] + public void FailedMonsterAssess_DoesNotEmitInventedAssessmentIncompleteLiteral() + { + // Regression pin (review F14): pre-AS2, a FAILED monster assess + // (character:false — no String 5 Template, no Int 0x105 marker) was + // the exact case that set 0x1000053A to the invented, zero-retail- + // provenance "Assessment incomplete" literal. The monster branch of + // ApplyCreature never composes 0x1000053A at all (see + // CreatureResponse_HeaderIdentityElementsUnaffectedByPlayerFix + // above), so the element must stay cleared/empty here too, success + // or not. + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Drudge", + Type = ItemType.Creature, + }); + using var interaction = NewInteraction(objects, []); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { }, + creatureNames: new CreatureDisplayNameResolver( + new Dictionary { [11u] = "Drudge" }))!; + interaction.ExamineSelectedOrEnterMode(ObjectId); + + var properties = new PropertyBundle(); + properties.Ints[2u] = 11; // CreatureType — no String 5 / Int 261 marker present. + + Assert.True(controller.Apply( + Parsed(properties, MinimalCreatureProfile(), success: false))); + Assert.Equal(AppraisalView.Creature, controller.ActiveView); + + Assert.Equal(string.Empty, HeaderText(layout, 0x1000053Au)); + } + [Fact] public void ResponseForNeitherPendingNorCurrent_IsIgnored() { From bde5cae03181cad5c49bd42f8c8370058576b832 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 09:05:08 +0200 Subject: [PATCH 66/89] docs(AS): AS2 REVIEW-CLOSED in the ledger; paperdoll-bullet phrasing polish Re-review of cc5290af accepted all six findings; the retail port is exact. The paperdoll ruling bullet now reads cleanly (the retail-colors clause was dangling off the decomp citation) and points at AD-114. The three remaining Core-side PK-bit copies are flagged as a follow-up chip outside AS scope. Co-Authored-By: Claude Fable 5 --- ...026-08-25-assess-window-parity-campaign.md | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/docs/plans/2026-08-25-assess-window-parity-campaign.md b/docs/plans/2026-08-25-assess-window-parity-campaign.md index 98747359..88985783 100644 --- a/docs/plans/2026-08-25-assess-window-parity-campaign.md +++ b/docs/plans/2026-08-25-assess-window-parity-campaign.md @@ -23,17 +23,19 @@ Missing vs retail: Explicit owner rulings: -- **The animated 3D paperdoll is an INTENTIONAL acdream deviation.** Retail's - examine preview clone is NOT a static tinted preview — it is - INDEPENDENTLY ANIMATED, just decoupled from the live target: - `BasicCreatureExamineUI::Init @0x004AB9C0` clones the selected object via - `CPhysicsObj::makeObject @0x005144B0` (which runs `MorphToExistingObject` - then `play_script_internal(setup->default_script_id)`), sets the clone's +- **The animated 3D paperdoll is an INTENTIONAL acdream deviation** + (register row AD-114, filed at AS2). Retail's examine preview clone is + NOT a static tinted preview — it is INDEPENDENTLY ANIMATED, just + decoupled from the live target: `BasicCreatureExamineUI::Init + @0x004AB9C0` clones the selected object via `CPhysicsObj::makeObject + @0x005144B0` (which runs `MorphToExistingObject` then + `play_script_internal(setup->default_script_id)`), sets the clone's heading to 191.367905°, and `CreatureMode::Render @0x004529D0` runs - `update_position` on it every frame — its colors are buggy in retail. - acdream's deviation is that our preview mirrors the target's LIVE motion - instead of playing its own private, decoupled cycle. Keep ours. Register - row required (added in the first slice that touches the window). + `update_position` on it every frame. acdream's deviation is that our + preview mirrors the target's LIVE motion instead of playing its own + private, decoupled cycle. Keep ours. (Retail's preview colors are also + buggy on the owner's reference setup, so porting the decoupled clone + would not even be a faithfulness win.) - Retail comparison is the oracle for text composition; all strings come from DAT StringTables per the decomp — **never hardcoded English literals**. @@ -135,8 +137,8 @@ round → narrow re-review → REVIEW-CLOSED. | Slice | State | Land / fix commits | Notes | |---|---|---|---| | AS1 | **DONE 2026-08-25** | (docs commit) | 3-agent research; ground-truth doc committed | -| AS2 | review fix round | `f8a22589` | fix round (this commit) | -| AS3 | pending AS2 review-close | — | | +| AS2 | **REVIEW-CLOSED 2026-08-25** | `f8a22589` / `cc5290af` | port exact per dual-lens review; 6 findings (docs/test/refactor) fixed; +AD-114 (animated paperdoll), +AD-115 (title clear-vs-stale), PK bits promoted to `PublicWeenieFlags`; carried follow-up chip: 3 more Core PK-bit copies | +| AS3 | dispatched 2026-08-25 | — | | | AS4 | pending AS3 review-close | — | | | AS5 | pending AS4 review-close | — | | | AS6 | pending AS5 review-close | — | | From 1616cd3d39e0094a95d650b76cb65aad3e808129 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 09:16:17 +0200 Subject: [PATCH 67/89] =?UTF-8?q?feat(ui):=20Campaign=20AS=20AS3=20?= =?UTF-8?q?=E2=80=94=20per-bodypart=20armor-level=20rows=20(G4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plumbs Parsed.ArmorLevels into the extras composer and ports the retail armor-level trio + unenchantable legend for the player examination window's extras list (0x10000335), closing gap G4 and the legend half of G8 from docs/research/2026-08-25-campaign-as-ground-truth.md. Decomp evidence (docs/research/named-retail/acclient_2013_pseudo_c.txt): - CharExamineUI::SetAppraiseInfo @0x004B45F0: the armor-level trio (@0x004B4FD1-@0x004B5410) gates on ANY of nine base_armor_* fields > 0, emits one leading spacer, then three rows "Head/Chest/Groin" (Head, Chest, Abdomen), "Bicep/Wrist/Hand" (UpperArm, LowerArm, Hand), "Thigh/Shin/Foot" (UpperLeg, LowerLeg, Foot) formatted "AL: %s/%s/%s" with each part "%d" below 0x270f (9999) or "*%d" with (value-9999) at/ above it (data_794344 vs data_7b110c). The trio precedes the ratings block and has no trailing spacer of its own. - The "* = Unenchantable" legend (@0x004B5D7D-@0x004B5DED) is added UNCONDITIONALLY after the whole `if (InqCreature)` block closes — confirming ruling R3's "unconditional" reading directly from the raw decompile, not just the BN flattening theory. - CreatureExamineUI::SetAppraiseInfo @0x004B3FF0 (monster path): reads the same nine ratings properties with the same gating/spacer logic, but never touches base_armor_* or the unenchantable literal. Confirmed the monster (character:false) path gains neither the trio nor the legend — CreatureAppraisalRows.BuildExtra is character-gated for both. - Ruling R4 (spacer discipline): CharExamineUI's own ratings-block leading- spacer flag (ebx_13) is a known BN-decompiler artifact loss (call- argument mangling instead of a clean `= 1` assignment). Cross-checked against CreatureExamineUI's clean version of the identical algorithm: one leading spacer before the FIRST ratings-family row that fires, one trailing spacer if ANY fired. The existing BuildExtra ratings logic (per-row gates 307|313|314, 308|315|316, 350|351; single leading/ trailing spacer) already matched this exactly — no functional change to the ratings section, only the signature/threading change to make room for the trio and legend around it. Changed: - CreatureAppraisalRows.BuildExtra now takes (properties, armorLevels, character) instead of (properties) alone. Character-gated trio + legend wrap the unchanged ratings logic. - AppraisalUiController.RebuildCreatureStats takes the character flag and threads appraisal.ArmorLevels through; ApplyCreature passes its own `character` parameter. No caching needed for the combat refresh to keep the AL rows: AppraiseInfoParser always parses ArmorLevels into the fresh Parsed value Apply receives, so a re-Apply of the refreshed response renders the same rows for free. - Test signature updates only (no behavior pins changed) plus new coverage: ArmorLevelTrioUsesRetailGroupingLabelsAndFormatPrecedingRatings, ArmorLevelPartRendersUnenchantableSentinelAtOrAbove9999 (theory: 9998/ 9999/10123), ArmorLevelRowMixesStarredAndPlainPartsIndependently, AllNineArmorLevelsZeroOrNegativeEmitsNoTrioAndNoSpacer, ArmorLevelTrioAbsentWhenArmorLevelsIsNull, EachRatingRowGatesIndependently, LegendIsAbsentOnMonsterPathEvenWithRatingsShown, LegendIsAlwaysLastOnCharacterPathEvenWithNoOtherExtras (rows-level); CharacterResponse_ArmorLevelTrioPopulatesExtraListThroughRealBinding, CharacterResponse_CombatRefreshRetainsArmorLevelRows, CreatureResponse_NeverGainsArmorLevelTrioOrLegend (controller-level, through the real LayoutImporter/FixtureLoader binding seam). No existing pin was corrected — the pre-AS3 ratings gating/spacer behavior already matched the decomp; only the call signature changed. Full hermetic suite: AcDream.App.Tests 6208/0 skips; full-solution 15,483/0 skips. Release build green. Co-Authored-By: Claude Fable 5 --- .../UI/Layout/AppraisalUiController.cs | 22 +- .../UI/Layout/CreatureAppraisalRows.cs | 137 +++++++++--- .../UI/Layout/AppraisalUiControllerTests.cs | 196 +++++++++++++++++- .../UI/Layout/CreatureAppraisalRowsTests.cs | 173 +++++++++++++++- 4 files changed, 490 insertions(+), 38 deletions(-) diff --git a/src/AcDream.App/UI/Layout/AppraisalUiController.cs b/src/AcDream.App/UI/Layout/AppraisalUiController.cs index a522af1a..bac95ade 100644 --- a/src/AcDream.App/UI/Layout/AppraisalUiController.cs +++ b/src/AcDream.App/UI/Layout/AppraisalUiController.cs @@ -718,7 +718,7 @@ public sealed class AppraisalUiController : IRetainedPanelController _creatureNames.Resolve(GetInt(p, 2u))); } - RebuildCreatureStats(appraisal); + RebuildCreatureStats(appraisal, character); if (newlySelected) ResetCreatureScroll(); @@ -810,7 +810,22 @@ public sealed class AppraisalUiController : IRetainedPanelController private static string BuildAllegianceDisplay(PropertyBundle p) => GetInt(p, 30u) >= 1 ? GetString(p, 47u) : string.Empty; - private void RebuildCreatureStats(AppraiseInfoParser.Parsed appraisal) + /// + /// Rebuilds both authored appraisal lists from a freshly parsed response. + /// selects between the CharExamineUI + /// and CreatureExamineUI extras composition (armor-level trio + + /// unenchantable legend are CHAR-ONLY; see + /// ). appraisal + /// already carries ArmorLevels straight from the freshly parsed + /// wire response (AppraiseInfoParser parses it unconditionally + /// when the flag is set) — the 0.75 s combat refresh + /// (RefreshCurrentAppraisal) round-trips a + /// brand-new response through , so no separate cache + /// is needed for a refresh to keep rendering the same armor-level rows. + /// + private void RebuildCreatureStats( + AppraiseInfoParser.Parsed appraisal, + bool character) { if (_creatureStats is null || _creatureRowTemplates is null) return; @@ -820,7 +835,8 @@ public sealed class AppraisalUiController : IRetainedPanelController ? CreatureAppraisalRows.Build(profile, appraisal.Success) : Array.Empty()); _creatureExtra?.Rebuild( - CreatureAppraisalRows.BuildExtra(appraisal.Properties)); + CreatureAppraisalRows.BuildExtra( + appraisal.Properties, appraisal.ArmorLevels, character)); } private void ConfigureScrollableText( diff --git a/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs b/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs index c150ee3d..db38926a 100644 --- a/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs +++ b/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs @@ -88,17 +88,71 @@ public static class CreatureAppraisalRows } /// - /// Port of CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0's - /// separate miscellaneous list. Retail reads all nine rating properties, - /// emits a leading and trailing blank row when any display group exists, - /// and uses Crit/CritResist only to decide whether their paired row exists. - /// HealingBoost is read but not displayed by this retail build. + /// Armor-level unenchantable sentinel. Retail formats each per-part value + /// as "%d" (data_794344) below this threshold and + /// "*%d" (data_7b110c) with value - 9999 at/above it + /// — see e.g. CharExamineUI::SetAppraiseInfo + /// @0x004B5086-@0x004B50A5 (the head part) and every + /// subsequent part in the trio. /// + private const int UnenchantableArmorLevel = 9999; + + /// + /// Port of the armor-level trio + rating rows from + /// CharExamineUI::SetAppraiseInfo @ 0x004B45F0 (player path) and + /// CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0 (monster path, + /// ratings only — no armor-level trio and no unenchantable legend; the + /// monster function ends immediately after its own trailing ratings + /// spacer and never touches base_armor_* or the + /// u"* = Unenchantable" literal). Ground truth: + /// docs/research/2026-08-25-campaign-as-ground-truth.md §2b rows + /// 3-7 + 15, ruling R3 (legend unconditional) and R4 (spacer discipline). + /// + /// Rating gating/spacer logic: retail's CharExamineUI decompile + /// mangles its leading-spacer flag (ebx_13) into unreadable + /// call-argument artifacts — a known BN-decompiler loss (see + /// feedback_bn_decomp_field_names.md). Ruling R4 directs using + /// CreatureExamineUI's clean version of the identical algorithm + /// instead, which is what this method implements: one leading blank row + /// before the FIRST ratings-family row that fires, one trailing blank + /// row if ANY ratings-family row fired. Each of the three rating rows is + /// gated independently (307|313|314, 308|315|316, 350|351); 313/315 only + /// gate — their values (Crit/CritResist) are never displayed. + /// HealingBoost (Int 323) is read by retail and never rendered (ruling + /// R2) — discarded here too. + /// + /// + /// The response's parsed property tables. + /// + /// Parsed ArmorLevels blob (Success-only per ACE), or + /// when the response didn't carry one. + /// + /// + /// for the CharExamineUI (player) path, + /// which alone emits the armor-level trio and the trailing + /// "* = Unenchantable" legend; for the + /// CreatureExamineUI (monster) path, which never emits either. + /// public static IReadOnlyList BuildExtra( - PropertyBundle properties) + PropertyBundle properties, + AppraiseInfoParser.ArmorLevel? armorLevels, + bool character) { ArgumentNullException.ThrowIfNull(properties); + var rows = new List(); + + if (character && armorLevels is { } levels && HasAnyArmorLevel(levels)) + { + rows.Add(Blank()); + rows.Add(ArmorLevelRow( + "Head/Chest/Groin", levels.Head, levels.Chest, levels.Abdomen)); + rows.Add(ArmorLevelRow( + "Bicep/Wrist/Hand", levels.UpperArm, levels.LowerArm, levels.Hand)); + rows.Add(ArmorLevelRow( + "Thigh/Shin/Foot", levels.UpperLeg, levels.LowerLeg, levels.Foot)); + } + int damage = Get(properties, DamageRating); int damageResist = Get(properties, DamageResistRating); int crit = Get(properties, CritRating); @@ -113,38 +167,61 @@ public static class CreatureAppraisalRows bool showResist = damageResist > 0 || critResist > 0 || critDamageResist > 0; bool showDotLife = dotResist > 0 || lifeResist > 0; - if (!showRating && !showResist && !showDotLife) - return Array.Empty(); + if (showRating || showResist || showDotLife) + { + rows.Add(Blank()); + if (showRating) + { + rows.Add(new CreatureAppraisalRow( + "Dmg/CritDmg", + $"Rating: {Number(damage)}/{Number(critDamage)}", + CreatureAppraisalValueStyle.Normal)); + } + if (showResist) + { + rows.Add(new CreatureAppraisalRow( + "Dmg/CritDmg", + $"Resist: {Number(damageResist)}/{Number(critDamageResist)}", + CreatureAppraisalValueStyle.Normal)); + } + if (showDotLife) + { + rows.Add(new CreatureAppraisalRow( + "DoT/Life:", + $"Resist: {Number(dotResist)}/{Number(lifeResist)}", + CreatureAppraisalValueStyle.Normal)); + } + rows.Add(Blank()); + } - var rows = new List(5) - { - Blank(), - }; - if (showRating) + if (character) { rows.Add(new CreatureAppraisalRow( - "Dmg/CritDmg", - $"Rating: {Number(damage)}/{Number(critDamage)}", + "* = Unenchantable", + string.Empty, CreatureAppraisalValueStyle.Normal)); } - if (showResist) - { - rows.Add(new CreatureAppraisalRow( - "Dmg/CritDmg", - $"Resist: {Number(damageResist)}/{Number(critDamageResist)}", - CreatureAppraisalValueStyle.Normal)); - } - if (showDotLife) - { - rows.Add(new CreatureAppraisalRow( - "DoT/Life:", - $"Resist: {Number(dotResist)}/{Number(lifeResist)}", - CreatureAppraisalValueStyle.Normal)); - } - rows.Add(Blank()); + return rows; } + private static bool HasAnyArmorLevel(AppraiseInfoParser.ArmorLevel levels) + => levels.Head > 0 || levels.Chest > 0 || levels.Abdomen > 0 + || levels.UpperArm > 0 || levels.LowerArm > 0 || levels.Hand > 0 + || levels.UpperLeg > 0 || levels.LowerLeg > 0 || levels.Foot > 0; + + private static CreatureAppraisalRow ArmorLevelRow( + string label, int a, int b, int c) + => new( + label, + $"AL: {ArmorLevelPart(a)}/{ArmorLevelPart(b)}/{ArmorLevelPart(c)}", + CreatureAppraisalValueStyle.Normal); + + private static string ArmorLevelPart(int value) + => value >= UnenchantableArmorLevel + ? $"*{Number(value - UnenchantableArmorLevel)}" + : Number(value); + private static CreatureAppraisalRow Blank() => new(string.Empty, string.Empty, CreatureAppraisalValueStyle.Normal); diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs index 94b523bd..73366bb6 100644 --- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs @@ -802,6 +802,173 @@ public sealed class AppraisalUiControllerTests Assert.Equal(string.Empty, HeaderText(layout, 0x1000053Au)); } + // ── Campaign AS slice AS3: armor-level rows + extras-list plumbing ──── + // Ground truth: docs/research/2026-08-25-campaign-as-ground-truth.md + // §2b rows 3-7 + 15 (gap G4 + partial G8). Real LayoutDesc/template + // binding, matching the AS2 controller-level pattern. + + [Fact] + public void CharacterResponse_ArmorLevelTrioPopulatesExtraListThroughRealBinding() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Dww", + Type = ItemType.Creature, + }); + using var interaction = NewInteraction(objects, []); + var templates = new CreatureAppraisalRowTemplateFactory( + FixtureLoader.LoadExaminationRowTemplateInfos(), + NoTexture, + defaultFont: null); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { }, + templates)!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + var properties = new PropertyBundle(); + properties.Strings[5u] = "Template"; // Character-view marker + var armorLevels = new AppraiseInfoParser.ArmorLevel( + Head: 100, Chest: 110, Abdomen: 120, + UpperArm: 130, LowerArm: 140, Hand: 150, + UpperLeg: 160, LowerLeg: 170, Foot: 180); + + Assert.True(controller.Apply(Parsed( + properties, MinimalCreatureProfile(), armorLevels: armorLevels))); + Assert.Equal(AppraisalView.Character, controller.ActiveView); + + UiItemList extra = CreatureExtraList(layout); + Assert.Equal(5, extra.GetNumUIItems()); + Assert.Equal(("", ""), ExtraRow(extra, 0)); + Assert.Equal( + ("Head/Chest/Groin", "AL: 100/110/120"), ExtraRow(extra, 1)); + Assert.Equal( + ("Bicep/Wrist/Hand", "AL: 130/140/150"), ExtraRow(extra, 2)); + Assert.Equal( + ("Thigh/Shin/Foot", "AL: 160/170/180"), ExtraRow(extra, 3)); + Assert.Equal( + ("* = Unenchantable", string.Empty), ExtraRow(extra, 4)); + } + + [Fact] + public void CharacterResponse_CombatRefreshRetainsArmorLevelRows() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Dww", + Type = ItemType.Creature, + }); + var sent = new List(); + using var interaction = NewInteraction(objects, sent); + var combat = new CombatState(); + var templates = new CreatureAppraisalRowTemplateFactory( + FixtureLoader.LoadExaminationRowTemplateInfos(), + NoTexture, + defaultFont: null); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + combat, + [], + [], + () => { }, + () => { }, + templates)!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + var properties = new PropertyBundle(); + properties.Strings[5u] = "Template"; + var armorLevels = new AppraiseInfoParser.ArmorLevel( + Head: 50, Chest: 60, Abdomen: 70, + UpperArm: 80, LowerArm: 90, Hand: 100, + UpperLeg: 110, LowerLeg: 120, Foot: 130); + AppraiseInfoParser.Parsed appraisal = Parsed( + properties, MinimalCreatureProfile(), armorLevels: armorLevels); + + Assert.True(controller.Apply(appraisal)); + controller.OnShown(); + int sentBeforeRefresh = sent.Count; + + combat.SetCombatMode(CombatMode.Melee); + controller.Tick(0.75); + // The 0.75 s combat refresh fired exactly one fresh wire request. + Assert.Equal(sentBeforeRefresh + 1, sent.Count); + + // The refreshed response is a brand-new Parsed value coming back + // through Apply — AppraiseInfoParser always parses ArmorLevels when + // the flag is set, so nothing needs to be cached client-side for + // the re-applied response to keep rendering the same AL rows. + Assert.True(controller.Apply(appraisal)); + + UiItemList extra = CreatureExtraList(layout); + Assert.Equal(5, extra.GetNumUIItems()); + Assert.Equal( + ("Head/Chest/Groin", "AL: 50/60/70"), ExtraRow(extra, 1)); + Assert.Equal( + ("Bicep/Wrist/Hand", "AL: 80/90/100"), ExtraRow(extra, 2)); + Assert.Equal( + ("Thigh/Shin/Foot", "AL: 110/120/130"), ExtraRow(extra, 3)); + } + + [Fact] + public void CreatureResponse_NeverGainsArmorLevelTrioOrLegend() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Specter", + Type = ItemType.Creature, + }); + using var interaction = NewInteraction(objects, []); + var templates = new CreatureAppraisalRowTemplateFactory( + FixtureLoader.LoadExaminationRowTemplateInfos(), + NoTexture, + defaultFont: null); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { }, + templates)!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + // No String 5 / Int 261 marker -> monster path. ArmorLevels present + // on the wire (a real ACE response always carries them for a + // successful non-player target too) must still be ignored here. + var armorLevels = new AppraiseInfoParser.ArmorLevel( + Head: 100, Chest: 110, Abdomen: 120, + UpperArm: 130, LowerArm: 140, Hand: 150, + UpperLeg: 160, LowerLeg: 170, Foot: 180); + + Assert.True(controller.Apply(Parsed( + new PropertyBundle(), + MinimalCreatureProfile(), + armorLevels: armorLevels))); + Assert.Equal(AppraisalView.Creature, controller.ActiveView); + + UiItemList extra = CreatureExtraList(layout); + Assert.Equal(0, extra.GetNumUIItems()); + } + [Fact] public void ResponseForNeitherPendingNorCurrent_IsIgnored() { @@ -1263,7 +1430,8 @@ public sealed class AppraisalUiControllerTests PropertyBundle properties, AppraiseInfoParser.CreatureProfile? creature = null, uint guid = ObjectId, - bool success = true) + bool success = true, + AppraiseInfoParser.ArmorLevel? armorLevels = null) => new( Guid: guid, Flags: creature is null @@ -1276,7 +1444,7 @@ public sealed class AppraisalUiControllerTests CreatureProfile: creature, WeaponProfile: null, HookProfile: null, - ArmorLevels: null, + ArmorLevels: armorLevels, ArmorEnchantments: null, WeaponEnchantments: null, ResistEnchantments: null); @@ -1310,6 +1478,30 @@ public sealed class AppraisalUiControllerTests '\n', text.LinesProvider().Select(line => line.Text)); } + private static UiItemList CreatureExtraList(ImportedLayout layout) + { + UiElement extraHost = layout.FindElement( + AppraisalUiController.CreatureExtraListId)!; + UiElement creaturePanel = layout.FindElement( + AppraisalUiController.CreaturePanelId)!; + return Assert.Single( + creaturePanel.Children.OfType(), + candidate => candidate.Top == extraHost.Top); + } + + private static (string Label, string Value) ExtraRow( + UiItemList extra, int index) + { + var slot = Assert.IsType(extra.GetItem(index)); + string label = Assert.Single(((UiText)slot.Content.FindElement( + CreatureAppraisalRowTemplateFactory.LabelId)!) + .LinesProvider()).Text; + string value = Assert.Single(((UiText)slot.Content.FindElement( + CreatureAppraisalRowTemplateFactory.ValueId)!) + .LinesProvider()).Text; + return (label, value); + } + private static void AssertSpellText( ImportedLayout layout, uint elementId, diff --git a/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs b/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs index 926573b7..cca7ea3a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs @@ -61,7 +61,7 @@ public sealed class CreatureAppraisalRowsTests properties.Ints[0x15Fu] = 6; IReadOnlyList rows = - CreatureAppraisalRows.BuildExtra(properties); + CreatureAppraisalRows.BuildExtra(properties, armorLevels: null, character: false); Assert.Equal(5, rows.Count); Assert.Equal(("", ""), (rows[0].Label, rows[0].Value)); @@ -84,14 +84,181 @@ public sealed class CreatureAppraisalRowsTests critOnly.Ints[0x139u] = 8; IReadOnlyList rows = - CreatureAppraisalRows.BuildExtra(critOnly); + CreatureAppraisalRows.BuildExtra(critOnly, armorLevels: null, character: false); Assert.Equal(3, rows.Count); Assert.Equal("Rating: 0/0", rows[1].Value); var healingOnly = new PropertyBundle(); healingOnly.Ints[0x143u] = 20; - Assert.Empty(CreatureAppraisalRows.BuildExtra(healingOnly)); + Assert.Empty( + CreatureAppraisalRows.BuildExtra( + healingOnly, armorLevels: null, character: false)); + } + + // ── Campaign AS slice AS3: armor-level trio + extras-list ordering ──── + // Ground truth: docs/research/2026-08-25-campaign-as-ground-truth.md + // §2b rows 3-7 + 15, rulings R3 (legend unconditional) and R4 (spacer + // discipline). Decomp anchors: CharExamineUI::SetAppraiseInfo + // @0x004B45F0 (armor-level trio @0x004B4FD1-@0x004B5410, legend + // @0x004B5D7D-@0x004B5DED) and CreatureExamineUI::SetAppraiseInfo + // @0x004B3FF0 (ratings-only; no trio, no legend). + + [Fact] + public void ArmorLevelTrioUsesRetailGroupingLabelsAndFormatPrecedingRatings() + { + var levels = new AppraiseInfoParser.ArmorLevel( + Head: 100, Chest: 110, Abdomen: 120, + UpperArm: 130, LowerArm: 140, Hand: 150, + UpperLeg: 160, LowerLeg: 170, Foot: 180); + var properties = new PropertyBundle(); + properties.Ints[0x133u] = 35; // DamageRating -> also triggers ratings block + + IReadOnlyList rows = + CreatureAppraisalRows.BuildExtra(properties, levels, character: true); + + // [0] spacer, [1..3] AL trio, [4] spacer, [5] rating, [6] spacer, + // [7] legend. + Assert.Equal(8, rows.Count); + Assert.Equal(("", ""), (rows[0].Label, rows[0].Value)); + Assert.Equal( + ("Head/Chest/Groin", "AL: 100/110/120"), + (rows[1].Label, rows[1].Value)); + Assert.Equal( + ("Bicep/Wrist/Hand", "AL: 130/140/150"), + (rows[2].Label, rows[2].Value)); + Assert.Equal( + ("Thigh/Shin/Foot", "AL: 160/170/180"), + (rows[3].Label, rows[3].Value)); + Assert.Equal(("", ""), (rows[4].Label, rows[4].Value)); + Assert.Equal( + ("Dmg/CritDmg", "Rating: 35/0"), + (rows[5].Label, rows[5].Value)); + Assert.Equal(("", ""), (rows[6].Label, rows[6].Value)); + Assert.Equal( + ("* = Unenchantable", string.Empty), + (rows[7].Label, rows[7].Value)); + } + + [Theory] + [InlineData(9998, "9998")] + [InlineData(9999, "*0")] + [InlineData(10123, "*124")] + public void ArmorLevelPartRendersUnenchantableSentinelAtOrAbove9999( + int value, + string expected) + { + var levels = new AppraiseInfoParser.ArmorLevel( + Head: value, Chest: 0, Abdomen: 0, + UpperArm: 0, LowerArm: 0, Hand: 0, + UpperLeg: 0, LowerLeg: 0, Foot: 0); + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + new PropertyBundle(), levels, character: true); + + CreatureAppraisalRow row = Assert.Single( + rows, r => r.Label == "Head/Chest/Groin"); + Assert.Equal($"AL: {expected}/0/0", row.Value); + } + + [Fact] + public void ArmorLevelRowMixesStarredAndPlainPartsIndependently() + { + var levels = new AppraiseInfoParser.ArmorLevel( + Head: 50, Chest: 9999, Abdomen: 20000, + UpperArm: 0, LowerArm: 0, Hand: 0, + UpperLeg: 0, LowerLeg: 0, Foot: 0); + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + new PropertyBundle(), levels, character: true); + + CreatureAppraisalRow row = Assert.Single( + rows, r => r.Label == "Head/Chest/Groin"); + Assert.Equal("AL: 50/*0/*10001", row.Value); + } + + [Fact] + public void AllNineArmorLevelsZeroOrNegativeEmitsNoTrioAndNoSpacer() + { + var levels = new AppraiseInfoParser.ArmorLevel( + Head: 0, Chest: 0, Abdomen: -5, + UpperArm: 0, LowerArm: 0, Hand: 0, + UpperLeg: 0, LowerLeg: 0, Foot: 0); + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + new PropertyBundle(), levels, character: true); + + Assert.DoesNotContain(rows, r => r.Label.Contains("Groin")); + Assert.DoesNotContain(rows, r => r.Label.Contains("Hand")); + Assert.DoesNotContain(rows, r => r.Label.Contains("Foot")); + // Legend is still unconditional on the char path. + Assert.Equal("* = Unenchantable", rows[^1].Label); + } + + [Fact] + public void ArmorLevelTrioAbsentWhenArmorLevelsIsNull() + { + var properties = new PropertyBundle(); + properties.Ints[0x133u] = 35; + + IReadOnlyList rows = + CreatureAppraisalRows.BuildExtra(properties, armorLevels: null, character: true); + + Assert.DoesNotContain(rows, r => r.Value.StartsWith("AL:", StringComparison.Ordinal)); + } + + [Fact] + public void EachRatingRowGatesIndependently() + { + // Crit (313) alone gates the Rating row but never displays. + var critOnly = new PropertyBundle(); + critOnly.Ints[0x139u] = 1; + IReadOnlyList critRows = + CreatureAppraisalRows.BuildExtra(critOnly, armorLevels: null, character: false); + Assert.Equal(3, critRows.Count); + Assert.Equal(("Dmg/CritDmg", "Rating: 0/0"), (critRows[1].Label, critRows[1].Value)); + + // CritResist (315) alone gates the Resist row but never displays. + var critResistOnly = new PropertyBundle(); + critResistOnly.Ints[0x13Bu] = 1; + IReadOnlyList critResistRows = + CreatureAppraisalRows.BuildExtra(critResistOnly, armorLevels: null, character: false); + Assert.Equal(3, critResistRows.Count); + Assert.Equal( + ("Dmg/CritDmg", "Resist: 0/0"), + (critResistRows[1].Label, critResistRows[1].Value)); + + // 350/351 (DoT/Life) alone, independent of the other two families. + var dotLifeOnly = new PropertyBundle(); + dotLifeOnly.Ints[0x15Eu] = 4; + IReadOnlyList dotLifeRows = + CreatureAppraisalRows.BuildExtra(dotLifeOnly, armorLevels: null, character: false); + Assert.Equal(3, dotLifeRows.Count); + Assert.Equal( + ("DoT/Life:", "Resist: 4/0"), + (dotLifeRows[1].Label, dotLifeRows[1].Value)); + } + + [Fact] + public void LegendIsAbsentOnMonsterPathEvenWithRatingsShown() + { + var properties = new PropertyBundle(); + properties.Ints[0x133u] = 35; + + IReadOnlyList rows = + CreatureAppraisalRows.BuildExtra(properties, armorLevels: null, character: false); + + Assert.DoesNotContain(rows, r => r.Label == "* = Unenchantable"); + } + + [Fact] + public void LegendIsAlwaysLastOnCharacterPathEvenWithNoOtherExtras() + { + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + new PropertyBundle(), armorLevels: null, character: true); + + CreatureAppraisalRow only = Assert.Single(rows); + Assert.Equal(("* = Unenchantable", string.Empty), (only.Label, only.Value)); } [Fact] From bc48e3216a5495d2ea371818417ecbc007dd1312 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 09:26:15 +0200 Subject: [PATCH 68/89] docs(AS): AS3 REVIEW-CLOSED (APPROVE, no fix round); R3 legend hedge settled at source The AS3 dual-lens review verified the armor-level trio, sentinel, legend, and monster-path exclusion at offset level and disproved the R3 BN-flattening theory structurally (legend sits outside the InqCreature block, pseudo-C line 189962). The ratings adjudication resolved in the implementer's favor: the pre-AS3 composer already had retail's per-row gating and spacer discipline. NITs 11/12 + the AP-110 narrowing ride AS4. Co-Authored-By: Claude Fable 5 --- .../2026-08-25-assess-window-parity-campaign.md | 3 ++- .../2026-08-25-campaign-as-ground-truth.md | 14 ++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/plans/2026-08-25-assess-window-parity-campaign.md b/docs/plans/2026-08-25-assess-window-parity-campaign.md index 88985783..4acc57cb 100644 --- a/docs/plans/2026-08-25-assess-window-parity-campaign.md +++ b/docs/plans/2026-08-25-assess-window-parity-campaign.md @@ -138,7 +138,8 @@ round → narrow re-review → REVIEW-CLOSED. |---|---|---|---| | AS1 | **DONE 2026-08-25** | (docs commit) | 3-agent research; ground-truth doc committed | | AS2 | **REVIEW-CLOSED 2026-08-25** | `f8a22589` / `cc5290af` | port exact per dual-lens review; 6 findings (docs/test/refactor) fixed; +AD-114 (animated paperdoll), +AD-115 (title clear-vs-stale), PK bits promoted to `PublicWeenieFlags`; carried follow-up chip: 3 more Core PK-bit copies | -| AS3 | dispatched 2026-08-25 | — | | +| AS3 | **REVIEW-CLOSED 2026-08-25** | `1616cd3d` (no fix round) | APPROVE first pass — trio/legend/monster-path exact by offset-level decomp verification; R3 flattening theory disproven at source; ratings adjudication: pre-AS3 code already retail-exact. 5 NITs: 12 (legend-order comment), 11 (stronger refresh test) fold into AS4; 14/15 done in the close commit; 13 (geometry-keyed test helper) noted | +| AS4 | pending chip-session quiesce | — | AS4 also carries AS3 NITs 11+12 and the AP-110 narrowing | | AS4 | pending AS3 review-close | — | | | AS5 | pending AS4 review-close | — | | | AS6 | pending AS5 review-close | — | | diff --git a/docs/research/2026-08-25-campaign-as-ground-truth.md b/docs/research/2026-08-25-campaign-as-ground-truth.md index 32a54d5a..6fc952fc 100644 --- a/docs/research/2026-08-25-campaign-as-ground-truth.md +++ b/docs/research/2026-08-25-campaign-as-ground-truth.md @@ -216,10 +216,16 @@ auto-fail. Guid-not-found → `Flags=0, Success=0` only. 52 is absent for players; harmless either way. - **R2 (Int 323 HealingBoost):** retail reads it and never renders it; we keep discarding it. Do not add a row. -- **R3 (`* = Unenchantable` legend unconditional):** port as the decomp - shows — unconditional at the end of the char extras (it may be a BN - flattening; the CONNECTED GATE explicitly asks the owner to check retail - side-by-side; flip to conditional only on live evidence). +- **R3 (`* = Unenchantable` legend unconditional):** SETTLED AT SOURCE at + the AS3 review — the legend at `004b5d7d-004b5dcd` sits OUTSIDE the + `if (InqCreature(...))` block (opened `004b4638`, closed at pseudo-C line + 189962), so the BN-flattening theory is dead: retail adds it + unconditionally, into the same `m_extraInfoList` (0x10000335), text in + the LABEL slot. Retail's outside-the-gate placement is unobservable in + practice (the dispatcher only routes to CharExamineUI when a creature + profile exists), so "always, on the character path" is the faithful + port. The connected gate keeps its side-by-side check for the VISUAL + question only; do not re-investigate the structure. - **R4 (ratings spacer discipline):** the BN output lost the flag assignments in CharExamineUI; use `CreatureExamineUI::SetAppraiseInfo @0x004B3FF0`'s clean version of the SAME logic: one spacer before the From 4ade9b04279eae047ce4fac9a5f72021cf63ec7a Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 09:45:57 +0200 Subject: [PATCH 69/89] =?UTF-8?q?feat(ui):=20Campaign=20AS=20AS4=20?= =?UTF-8?q?=E2=80=94=20society/allegiance/fellowship=20+=20configurable=20?= =?UTF-8?q?extras=20(G6/G7/G8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the last three retail extras-list row families from CharExamineUI:: SetAppraiseInfo @0x004B45F0 into CreatureAppraisalRows.BuildExtra, closing the character-path extras list end to end (AS2 header + AS3 armor-level/ ratings/legend + AS4 here). All rows are CHARACTER-path only; the monster path (CreatureExamineUI::SetAppraiseInfo @0x004B3FF0) is unaffected and pinned by a controller-level regression test. Society row (gap G6, @0x004b49a1-@0x004b4c24): gated on PropertyInt 281 (Faction1Bits) being PRESENT — a literal reading of InqInt's found/not- found return, not the ground-truth doc's informal "!= 0" value test. Bit-priority if/else-if chain (Celestial Hand 0x1 -> Eldrytch Web 0x2 -> "???" when Radiant Blood's 0x4 bit is ALSO clear -> else Radiant Blood) comes straight off the decompiled branches. Rank-band suffix boundaries (1-100 Initiate / 101-300 Adept / 301-600 Knight / 601-1000 Lord / 1001-1500 Master, no suffix outside every band) read directly off the decomp's inclusive range checks @0x004b4ab9-@0x004b4b92 and match the ground-truth table exactly. Color rule: green when the LOCAL player shares the target's selected bit (checked first, so extra local bits don't override a match), red when local has a different bit but not the target's, normal when local has no society bits; the unrecognized "???" branch never gets a color (retail's ebx_3 stays at its zero initializer). The local player's own Faction1Bits comes from a new pure `localFactionBits` parameter on BuildExtra — the composer never reads state directly. Monarch/Patron/Followers cascade (gap G7, @0x004b4d97-@0x004b4f54): gated by the caller on AllegianceRank (Int 30) >= 1, the SAME InqInt read AS2's header AllegianceName binding already consumes. Four arms in retail's exact order: MonarchsTitle (Str 21) absent -> "Alleg. Monarch:" + clamped "%d Follower"/"%d Followers" (Int 35, singular only at exactly 1); present + PatronsTitle (Str 35) absent -> "Monarch:" only; both present and ordinally equal -> one "Monarch/Patron:" row; both present and different -> "Monarch:" then "Patron:". Configurable extras (gap G8, @0x004b58be-@0x004b5c4d): Fellowship (Str 10), Arrived in Dereth (Str 43 DateOfBirth), Time in Dereth (Int 125 Age via the ALREADY-PORTED RetailDurationText.Format — the same ClientUISystem::DeltaTimeToString @0x00565E10 the decomp calls at @0x004b59e0, reused rather than re-ported), Chess Rank (Int 181), Fishing Skill (Int 192), Deaths (Int 43 NumDeaths, <= 0 -> "Has never died" with the SAME "Deaths:" label, verified in the decomp), Titles Earned (Int 262). Each row gates independently on its own property being PRESENT (server already strips these per the target's visibility options per ground truth §3) — no client-side option or success gating added. No spacers separate these seven rows, matching the decomp exactly. Seam: AppraisalUiController takes a new `Func _localFactionBits` dependency (per-call, never captured once — the secure-trade deferred-Func lesson), invoked only on the character path. AppraisalRuntimeBindings gained `LocalFactionBits`, wired in InteractionRetainedUiComposition from `d.Character.LocalPlayer.Properties.GetInt(281)` — the SAME LocalPlayerState instance CharacterSheetProvider already reads from, no new state path. AS3 NIT 11: CharacterResponse_CombatRefreshRetainsArmorLevelRows now applies a SECOND response with different armor-level values (proving the refresh re-renders from fresh data) then a THIRD with armorLevels: null (proving it clears). AS3 NIT 12: BuildExtra's XML doc now documents the full authored row order with the @0x004b5d7d legend anchor. The test Parsed() helper now ORs IdentifyResponseFlags.ArmorLevels into Flags whenever armorLevels is supplied (realism sub-nit), applying uniformly to every existing armor-level test in the file. Ground-truth doc imprecisions found while verifying against the decomp directly: (1) the Society gate is presence-of-property, not value != 0 — InqInt's return is a found/not-found bool, the summary's "!= 0" phrasing describes the common case but not the literal branch condition; (2) the Society color rule's bit-priority (same-bit match checked before the other-bits check) wasn't spelled out in the summary table, only "same/ different/none" — confirmed exact by reading all three branches (@0x004b49fd/@0x004b4a49/@0x004b4a8b). No other disagreements found. Register: docs/architecture/retail-divergence-register.md row AP-110 retires the "exhaustive character detail regions" clause from its still-lacks list with a dated 2026-08-25 narrowing note (AS2+AS3+AS4 together closed the character-path extras list); the row's other residuals (item-object preview, effective shield projection, cooldown- remaining, augmentation-cost StringInfo, creature FontInfo-list selection) are untouched. Tests: 51 new rows-level tests in CreatureAppraisalRowsTests.cs (society gate/bit-priority/band-boundaries/color-vs-local-faction, the full allegiance cascade incl. follower singular/plural/clamp, each configurable extra present/absent, "Has never died", monster-path regression, one complete ordering-pin snapshot) plus 3 new controller- level tests in AppraisalUiControllerTests.cs through the real LayoutDesc/ template binding (localFactionBits seam wired end to end, its default fallback, and the strengthened monster-path regression). Hermetic AcDream.App.Tests: 6253 passed. Full-solution hermetic run: 15,410 passed across all projects, 0 failed. Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 2 +- .../InteractionRetainedUiComposition.cs | 10 +- .../UI/Layout/AppraisalUiController.cs | 29 +- .../UI/Layout/CreatureAppraisalRows.cs | 315 ++++++++++++++++- src/AcDream.App/UI/RetailUiRuntime.cs | 13 +- .../UI/Layout/AppraisalUiControllerTests.cs | 174 ++++++++- .../UI/Layout/CreatureAppraisalRowsTests.cs | 329 ++++++++++++++++++ 7 files changed, 834 insertions(+), 38 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 26f05f47..3ed8bdff 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -361,7 +361,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | | AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` | | AP-109 | **NARROWED FURTHER 2026-08-25 at the Campaign CT4 fix round — the luminance pair's TEXT is now bound and the PK classification now reads the live PWD bits, closing both out of this row.** `CharacterSheetProvider.PkStatusText` classifies off `ClientObject.PublicWeenieBitfield` bits `0x20` (IsPK) / `0x02000000` (IsPKLite) — the exact `ACCWeenieObject::IsPK @0x0058c8b0` / `IsPKLite @0x0058c8a0` PWD-bitfield reads, ported already at `PlayerKillerStatusBitfield.Apply` (#297) — instead of the CT4-landed bitwise test against raw PropertyInt 134 (a non-retail mapping: PropertyInt 134 carries ACE's own `PlayerKillerStatus` enum values, not the PWD bit layout). `CharacterStatController`'s luminance pair (`0x100005C5`/`0x100005C6`) now binds real text: caption `"Luminance:"` (UTF-16, PE-byte-recovered from the `gmStatManagementUI` vftable-adjacent data region at `@0x007c3dd4`) and value `" / "` (narrow `"%s / %s"` format, PE-byte-recovered at `@0x007c3dcc`, args in that order per `UpdateExperience`'s call sequence — `ExperienceSystem::XPToString(AvailableLuminance, ...)` then `XPToString(MaximumLuminance, ...)`), both numbers formatted through the same shared `FormatXp` helper the Total XP / XP-to-level fields use (`.ToString("N0", CultureInfo.InvariantCulture)` — the C# equivalent of retail's `XPToString`→`GetNumberFormatA` locale-grouped-decimal call; not a byte-identical Win32 port, so an exotic edge case, e.g. negative/overflow, is this row's own residual sliver if one is ever found). The hide path is retail's own `UIElement_Text::ClearAllText` (`@0x004f0e31`/`@0x004f0e3c` — empties `LinesProvider` content, leaves layout) rather than `Visible = false`. (CT3's Titles-page narrowing, restored here verbatim after CT4's edit compressed it to a bare pointer phrase, still stands:) **CT3's narrowing (2026-08-24), verbatim:** `CharacterTitlesController` binds the Titles page (`gmCharacterTitleUI`, LayoutDesc `0x2100002E` element `0x10000539`) through the standard `UiTemplateListBox`/`UiScrollbar`/`UiButton` classes — no bespoke widgets: the earned-titles list sorted by resolved display string (`FindSortedInsertPosition @0x0049A760`), the current display-title text (`Refresh @0x0049abc0`, including its hardcoded `"Unknown"` fallback, refreshed on both the table-replace and display-change notices), row selection using the row template's own authored Highlight state (the same `InfoRegion::SetState(6)` mechanism CT1 confirmed for the stat rows), the "Set as Display Title" button's Ghosted-unless-a-differing-selection gate (`UpdateButtons @0x0049A500`, CORRECTED direction per the CT campaign plan's CT1 fix round — no selection is the Ghosted case), and the `TitleSet (0x002C)` wire send through CT2's `RuntimeCharacterTitleState`/`IRuntimeCharacterCommands.SetTitle` (no local mutation). Campaign CT slice CT4 (2026-08-24) then put the header identity block live: `CharacterStatController`'s Name/Heritage/PkStatus/Level labels use `LabelAuthoredColor` (the widget's own DAT-set `DefaultColor`/Outline, matching CT1's live-DAT pin — the former hardcoded `Body`/`Gold` runtime constants are deleted); the heritage line appends CT2/CT3's resolved display title VERBATIM (`CharacterIdentityText.StatHeaderLine`, CT4-fix-round-corrected 2026-08-25 to stop stripping a leading "The " — retail `AppendText`s the resolved title unmodified at `@0x004f0990`, and 26 real ACE `CharacterTitle` entries begin with "The"); the level shows `"%d"`-formatted `InqInt(0x19)` or the literal `"???"` when absent (both PE-recovered). One item remains open, registered rather than silently dropped: the NAME line ships the PLAIN-NAME case only — retail's allegiance-rank prefix (`AllegianceData::GetFullName @0x005b6950` → `AllegianceSystem::GetTitle @0x005b8dd0`) needs a ~170-string, **17-function** [CORRECTED 2026-08-25 from CT4's original 22-function/~200-string estimate — `GetTitle`'s own dispatch switch (`@0x005b8dd0`) was read directly: Gearknight and Tumerok author only a MALE `Get*Title` function, reused for both genders' dispatch branches, and Lugian authors only a FEMALE one, reused for both — 11 heritages produce 17 functions, not 22 (2 each for Aluvian/Gharu'ndim/Sho/Viamontian/Shadowbound/Empyrean/Undead, 1 each for Gearknight/Tumerok/Lugian); Olthoi/OlthoiAcid (heritage ids 12/13) have no title function at all — `GetTitle`'s own range check `(heritage-1) <= 0xa` (unsigned) excludes them, and heritage id `0xa` (Penumbraen) aliases to the Shadowbound functions] heritage×gender title table (verbatim in the decomp, e.g. `GetAluvianMaleTitle @0x005b7bc0`'s "Yeoman"/"Baronet"/.../"High King") that CT4 judged out of "reasonable size" for this slice. The RANK value is PropertyInt `0x1E` (`AllegianceRank`) read LIVE off the qualities bundle (`CBaseQualities::InqInt(qualities, 0x1e)` — ACE actively pushes this property on every allegiance-rank change) [CORRECTED 2026-08-25 — CT4's original text claimed `RuntimeAllegianceState` "already carries the local player's own rank," conflating this row's context with `SocialAllegiancePageController`'s OWN, DIFFERENT, already-documented substitution (that controller has no qualities-bundle access, so it renders `RuntimeAllegianceSnapshot.Rank` — same `0x0020 AllegianceUpdate` wire message, numerically equivalent in every observed case — as its own accepted stand-in). `CharacterSheetProvider.BuildSheet` already reads every other header property straight off `props.GetInt(...)` from the qualities-equivalent `PropertyBundle`, so the correct future port reads `props.GetInt(0x1Eu)` directly, not `RuntimeAllegianceState` — only the STRING table is missing, not the data]. | `src/AcDream.App/UI/Layout/CharacterStatController.cs` (`LabelAuthoredColor`, `RefreshLuminanceVisibility`, `FormatXp`); `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`PkStatusText`); `src/AcDream.Core/Items/ClientObject.cs` (`PlayerKillerStatusBitfield`); `src/AcDream.App/UI/Layout/CharacterSheet.cs`; `src/AcDream.App/UI/Layout/CharacterIdentityText.cs`; `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs`; `src/AcDream.App/UI/Layout/CharacterTitlesController.cs` (Titles page, CT3, unchanged) | Attributes/skills core output and the Titles-page binding seam are user-accepted; evidence for the header-identity block is synthetic-layout binding tests plus a small number of InstalledDat string/DID pins (the three PK strings, the gender/heritage EnumMapper chain at AP-235) — not a connected/live gate | A ranked-allegiance character's Name line shows plain name only (no title prefix) until the 17-function table is ported | `gmStatManagementUI::UpdateCharacterInfo @ 0x004F0770`; `UpdatePKStatus @ 0x004F00A0`; `UpdateExperience @ 0x004F0A70`; `UIElement_Text::ClearAllText @ 0x004F0E31`/`0x004F0E3C`; `ACCWeenieObject::IsPK @ 0x0058C8B0`; `IsPKLite @ 0x0058C8A0`; `AllegianceData::GetFullName @ 0x005B6950`; `AllegianceSystem::GetTitle @ 0x005B8DD0` | -| AP-110 | **NARROWED 2026-08-09 (Slice 5.4, vendor browse panel) — "vendor" retired from the absent-panels list; see AP-161 for the precise successor (Buy/Sell transaction UI, Slice 6).** Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, inscription transaction, retail creature stat/rating/animated-preview presentation, default selection-follow, authored local spell subview with appropriate-formula component state, and the full EoR item-report dispatch: appraisal-only unknowns; exact equipment-set/rating/tinkering/weapon/armor/caster/requirement/XP/healer/rare prose and intentional blank section rows; ordinary/enchantment DAT spell descriptions; live material-decorated appropriate titles plus DAT material and creature names; expiry, decorated material/gem descriptions; and portal/PK restrictions with authored item colors. It still lacks item-object preview, player-dependent effective shield projection, live cooldown-remaining projection, localized augmentation-cost `StringInfo`, exhaustive character detail regions, and exact creature appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, the Slice 5.4 vendor "Items" browse panel (category-filtered stock list, retail's quantity-correct pricing — `ItemHolder::GetObjectSplitSize`'s split-exempt mask, not a flat per-unit price), and the core examination request/presentation/inscription/creature-preview/item-report loop cover the active loops; the residual examination mechanisms require live player/enchantment/localization state or object-preview ownership rather than fabricated content | Item assessments omit only the listed live/localized/preview projections; enchanted/incomplete creature appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `gmExaminationUI::ExamineSpell @ 0x004B6900`; `SpellExamineUI::ExamineSpell @ 0x004B6210`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ACCWeenieObject::GetObjectName @ 0x0058E6E0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::AddItemInfo @ 0x004AC050`; `ItemExamineUI::Appraisal_ShowCapacity @ 0x004B2680`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::Appraisal_ShowDescription @ 0x004B6990`; `MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog | +| AP-110 | **NARROWED 2026-08-25 (Campaign AS, the assess/examination-window retail-parity campaign) — "exhaustive character detail regions" retired from the still-lacks list.** AS2 ported the player header identity block (composed gender+heritage, current display title, PK status from the local weenie's PWD bits, allegiance name); AS3 ported the per-bodypart armor-level trio (with the `*` unenchantable sentinel), the ratings-family spacer discipline, and the unconditional `* = Unenchantable` legend; AS4 ported the remaining extras-list rows — society/faction (rank bands, local-vs-target faction color rule), the Monarch/Patron/Followers cascade, and the seven configurable extras (Fellowship, Arrived in Dereth, Time in Dereth, Chess Rank, Fishing Skill, Deaths, Titles Earned) — closing the character-path extras list end to end. See `docs/research/2026-08-25-campaign-as-ground-truth.md` for the full row-by-row decomp citations; the row's OTHER residuals (item-object preview, effective shield projection, cooldown-remaining, augmentation-cost `StringInfo`, creature FontInfo-list selection) are untouched by this campaign and remain open below. **NARROWED 2026-08-09 (Slice 5.4, vendor browse panel) — "vendor" retired from the absent-panels list; see AP-161 for the precise successor (Buy/Sell transaction UI, Slice 6).** Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, inscription transaction, retail creature stat/rating/animated-preview presentation, default selection-follow, authored local spell subview with appropriate-formula component state, and the full EoR item-report dispatch: appraisal-only unknowns; exact equipment-set/rating/tinkering/weapon/armor/caster/requirement/XP/healer/rare prose and intentional blank section rows; ordinary/enchantment DAT spell descriptions; live material-decorated appropriate titles plus DAT material and creature names; expiry, decorated material/gem descriptions; and portal/PK restrictions with authored item colors. It still lacks item-object preview, player-dependent effective shield projection, live cooldown-remaining projection, localized augmentation-cost `StringInfo`, and exact creature appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, the Slice 5.4 vendor "Items" browse panel (category-filtered stock list, retail's quantity-correct pricing — `ItemHolder::GetObjectSplitSize`'s split-exempt mask, not a flat per-unit price), and the core examination request/presentation/inscription/creature-preview/item-report loop cover the active loops; the residual examination mechanisms require live player/enchantment/localization state or object-preview ownership rather than fabricated content | Item assessments omit only the listed live/localized/preview projections; enchanted/incomplete creature appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CharExamineUI::SetAppraiseInfo @ 0x004B45F0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `gmExaminationUI::ExamineSpell @ 0x004B6900`; `SpellExamineUI::ExamineSpell @ 0x004B6210`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ACCWeenieObject::GetObjectName @ 0x0058E6E0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::AddItemInfo @ 0x004AC050`; `ItemExamineUI::Appraisal_ShowCapacity @ 0x004B2680`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::Appraisal_ShowDescription @ 0x004B6990`; `MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog | | AP-161 | **REVIEW CORRECTIONS 2026-08-09 (Opus review of `92ea3977`, findings F1-F13):** thirteen further fixes, mostly bug-fixes-to-already-claimed-behavior rather than new divergences, so no new AP row is filed for most of them; the exceptions are called out below. F1 ports Buy All's four retail pre-send guards (pyreal/alt-currency affordability, container-slot/item-slot capacity) — see AP-162's narrowing. F2 corrects `AddToBuyList` from upsert to retail's actual ACCUMULATE-with-5000-cap semantics and ports `RemoveFromShop`'s shop-row hide/restore as staging consumes limited vendor supply. F3 corrects `VendorSellAcceptability`'s too-valuable branch to the byte-verified bitwise-complement form (`(~(itemTypeMask >> 16)) & 4`), exempting `PromissoryNote` items. F4 wires `BF_RETAINED` end to end, RETIRING AP-164 below. F5 ports `UpdateDragOver`'s auto-switch-to-Selling-on-hover. F6 corrects sell staging to always record the FULL stack (never the live split slider) and ports `SellSingleItem`'s partial-stack refusal plus its literal amount-1 send. F7 corrects the X-close confirmation string's missing trailing question mark. F8 disposes a live confirmation dialog on session Close/Reset. F9 repaints the Buying/Selling strips' own selection highlight on every selection change, not just a staging change. F10 unstages a sell entry that leaves `ClientObjectTable` and a buy entry whose shop row is retired, the latter with retail's exact notice. F11 reorders `RequestUse`'s eligibility check ahead of `BeginApproach` so an ineligible far target no longer speculatively approaches. F13 makes Sell Item act on the global selection unconditionally, matching retail — a prior version of this port required a staged entry first. New approximations this pass introduced are filed as AP-167 (`SellSingleItem`'s non-empty-container refusal branch not ported) and AP-168 (Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container`). **NARROWED 2026-08-09 (Slice 6b/6c, staging+sell arc) — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES.** `VendorUiController` now fully wires both tabs: Buying (`Add to List`/`Buy Item`/`Buy All`/`Clear Item`/`Clear List`, backed by `VendorStagingList`) and Selling (drag-to-sell via `IItemListDragHandler`, `VendorSellAcceptability`'s port of `VendorProfile::InqAcceptability`, `Sell Item`/`Sell All`/`Clear Item`/`Clear List`), plus the X-close staging confirmation dialog (`RetailDialogFactory`, the exact retail string recovered from the decompiled binary's data segment at `0x007b5bd8`). Sell (`0x0060`) is wired end to end (`VendorRequests.BuildSell`/`WorldSession.SendSell`/`ItemInteractionController.TrySell`). Three narrow residuals from this pass are filed separately rather than folded in here: `InqAcceptability`'s non-sellable bitfield check is unmodeled (AP-164), the Buying tab's stackable-removal-amount test substitutes `VendorShopItem.DescStackSize` for retail's `_maxStackSize` (AP-165), and the Buying/Selling tabs' own per-row/purse count text plus the cross-panel "pending sell" inventory highlight are not wired (AP-166). The two PRE-EXISTING residuals below (dropdown arrow-cap glyph, alt-currency `m_last_sale` simplification) are UNCHANGED by this pass — see the ORIGINAL text below for their citations. **REVIEW CORRECTIONS 2026-08-09 (Opus review of `97cf8738`, findings F1-F9):** none of these are NEW divergences from retail — they are bug fixes that make this row's own claims actually true, so no new AP row is filed for them. F2 fixed the priced/named quantity freezing at a selection-time seed while the Buy button separately read the LIVE slider — both now share one `ResolveBuyQuantity` computation, so the displayed price always equals what a purchase actually charges (retail: `gmVendorUI::RecvNotice_StackSliderChanged` re-runs the SAME display update on every slider change, `pc:203262-203278`). F6 corrected an unauthored "preserve the prior selection if it survives the filter" rule to retail's actual UNCONDITIONAL reselect-to-first-item on every rebuild this controller reaches (`VendorItemsUI::UpdateItemsList`'s notify=1 path, `pc:201180-201184`, confirmed reached by a fresh open AND a same-vendor refresh via `VendorItemsUI::OpenVendor`'s unconditional `SetSelectedItem(...,1)`, `pc:201022`). F7 ported `BuySingleItem`'s stack-size-1 quantity clamp (`pc:201674-201681`) so a stale slider value left over from a previously-selected, DIFFERENT stackable item cannot leak into a non-stack purchase. F8 is recorded inline below, where it corrects this row's own stale claim about the Add-to-List button. **NARROWED 2026-08-09 (Slice 6.1-6.3, buy arc) — TWO of the four consciously-deferred residuals below CLOSE.** Private per-panel selection is GONE: `SelectionState` gains a `Vendor` change source (`SelectionChangeSource.Vendor`) and is now the AUTHORITY — row clicks, the F4 auto-select-first-item fallback, and right-click examine all call `SelectionState.Select`/`Clear`; `VendorUiController` is a CONSUMER (`OnSelectionTransition`) exactly like every sibling panel, matching retail's global `ACCWeenieObject::selectedID`. The examine gap (F7c) is GONE too: `VendorShopItemMaterializer` (`src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs`, Slice 6.1) registers every `ApproachVendor` shop item into `ClientObjectTable` (guid, `ContainerId = vendorGuid`, merge-upserted via the ordinary `Ingest` path, retired on session Close/Reset/vendor-replace via the SAME `VendorState.Changed` subscription) so `AppraisalUiController.Apply`'s lookup now succeeds; `VendorUiController.ExamineItem` wires `UiItemList.ExamineItemRequested` to `ItemInteractionController.ExamineSelectedOrEnterMode`, mirroring `ExternalContainerController`. **Double-click-to-buy was investigated (research doc `docs/research/2026-08-08-slice6-vendor-transactions-research.md` §B.2) and confirmed ABSENT from retail** — no `gmVendorUI::CheckForDoubleClick`/`VendorItemsUI::CheckForDoubleClick` symbol exists anywhere in the 18,366-function named table, unlike sibling panels (`gmContractsUI::CheckForDoubleClick`, `gmPageListUI::CheckForDoubleClick`) that DO have one; acdream intentionally does NOT add a double-click shortcut — a user request for it as a deliberate acdream-only UX addition would need its own AP row, per CLAUDE.md's no-invented-mechanisms discipline. The remaining two residuals (dropdown arrow-cap glyph, alt-currency `m_last_sale` simplification) are UNCHANGED — see below. New approximations this pass introduced are filed separately as AP-162 (no client-side Buy pre-check) and AP-163 (shop-item guid-collision policy). **Original REWRITTEN text follows, retained for the two still-open residuals:** `VendorUiController` mounts LayoutDesc `0x21000012`/root `0x100000B7` and fully wires only the authored "Items" tab (`0x100000B9` — `VendorItemsUI`: category-filtered browse list with retail's quantity-correct pricing, `ItemHolder::GetObjectSplitSize`'s `0xDC41CB0` split-exempt mask ported locally rather than a flat per-unit price). The other two authored tabs render and switch pages (so the layout looks complete) but are otherwise INERT: "Buying" (`0x100000BA`, `VendorBuyUI` — staged-purchase review/confirm, buttons `0x100000C9`/`CA`/`CB`/`CC`) and "Selling" (`0x100000BB`, `VendorSellUI` — staged-sale review/confirm, buttons `0x100000D2`/`D3`/`D4`/`D5`) have no data binding at all. The "Items" page's own `Buy` button (`0x100000C2`) correctly enables/disables with selection (`UiButton.Enabled`, retail `SetState(1)`/`SetState(0xd)`) and Slice 6.3 wires it to a real immediate single-item purchase (`gmVendorUI::BuySingleItem`, `pc:201661` — `VendorRequests.BuildBuy`/`WorldSession.SendBuy`, opcode `0x005F`). **Review correction 2026-08-09 (F8):** `Add to List` (`0x100000C3`, staging) does NOT enable/disable with selection — it is PERMANENTLY disabled (`VendorUiController.SetActionButtonsEnabled`), because it has no wired `OnClick` at all; an enabled-but-dead button is a worse affordance than a disabled one, so it stays disabled until the "Buying" tab's staging list is actually implemented. The Buy opcode exists on the wire now; Sell (`0x0060`) does not. `VendorProfile::InqAcceptability` (sell-eligibility filtering) is unread — moot without a sell UI. Two divergences remain of the four the F1-F8 fix pass originally recorded — the other two (private per-panel selection, unwired shop-item examine) CLOSED at Slice 6.1/6.2, see the NARROWED note above: (1) the closed-dropdown button face reuses the row template's own two sprites (`0x060012B3` normal/`0x060012B4` open) through `UiMenu`'s existing single-texture 3-slice `DrawButtonFace` instead of retail's authored two-piece label+arrow-cap assembly (label `0x1000034D` + a separate 17x19 arrow cap `0x1000034E` with its own `0x060012B1`/`0x060012B2` states) — a cosmetic gap only; the popup panel and its rows render with the exact authored geometry and sprites; (2) the alt-currency "you have" holding reads `VendorShopProfile.AlternateCurrencyAmount` directly instead of tracking retail's `gmVendorUI.m_last_sale` purchase debit — moot until a sell path exists to actually debit it, since `m_last_sale` only changes on a completed SALE (retail's own `m_last_sale == 0` case, `pc:204091`/`OpenVendor`'s `this->m_last_sale = 0` reset at `pc:203790`/`203800`); Slice 6.3's buy path does not touch `m_last_sale` either (retail's own buy flow never writes it), so this residual is unaffected by the buy arc landing. The "Buying"/"Selling" staging tabs (`VendorBuyUI`/`VendorSellUI`) and the full Sell wire remain unwired — unchanged Slice 6b/6c territory per contract decision 6, not a residual of THIS row. | `src/AcDream.App/UI/Layout/VendorUiController.cs`; `src/AcDream.Core/Items/VendorState.cs`; `src/AcDream.Core.Net/GameEventWiring.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs` | Slice 6 (`docs/plans/2026-07-23-world-interaction-completion.md`) owns the authoritative buy/sell transaction command, quantity/stack-split selection, drag-to-sell consumption, and `InqAcceptability`-gated sell UI — Slice 5.4's charter was browse only. Buy (6.3), the global `ACCWeenieObject::selectedID` coupling (6.2), and shop-item `ClientObjectTable` registration (6.1) are now DONE, landing exactly the seam this row's original filing fenced off; drag-to-sell consumption and `InqAcceptability`-gated sell UI remain Slice 6b/6c territory. | A player can browse, select, examine, and BUY (Slice 6.3) — the only remaining unbuilt transaction is Sell. Clicking "Buying"/"Selling" still shows an empty page with no error or explanation, matching "present but does nothing" rather than a disabled/hidden affordance. The dropdown's closed-state button face is missing its separate arrow-cap glyph — a minor visual gap, not a functional one; the open popup itself is pixel-faithful to the authored template. | `gmVendorUI::OpenVendor` pc:203650 (`m_itemsUI`/`m_buyUI`/`m_sellUI` construction, `PostInit` pc:199906, `m_last_sale` reset pc:203790/203800); `VendorBuyUI::VendorBuyUI` pc:199717; `VendorSellUI::VendorSellUI` pc:199753; `VendorProfile::InqAcceptability` pc:484768-484797; `UIElement_Menu::MakePopup` pc:120705-120764, `::Initialize` pc:120789-120828; `VendorItemsUI::UpdateItemsUI` pc:202539-202820; `VendorItemsUI::UpdateItemsList` pc:201029-201190; `ItemHolder::GetObjectSplitSize` pc:401465-401477; `gmToolbarUI::HandleSelectionChanged` pc:198740-198790 (mask `0xDC41CB0` at pc:198784); `ACCWeenieObject::GetObjectName` pc:409056-409132; `docs/research/2026-08-08-slice5-vendor-browse-research.md` §B.4, §D | | AP-162 | **NARROWED 2026-08-09 (Opus review of `92ea3977`, finding F1) — the "Buy All" half of this row CLOSES.** `VendorUiController.BuyAllButtonPressed` now ports all four of retail's client-side pre-send guards (pyreal affordability `pc:204017`, alt-currency affordability `pc:204032`, container-slot capacity `pc:204053`, item-slot capacity `pc:204067`) — see `ComputeBuyTransactionValue`/`ComputeBuySlotsNeeded`/`CountPlayerContents`, each guard returning with staging fully intact and retail's own exact notice string (`"You don't have enough money"` at `0x007b57b4`, `"You must empty some slots in your backpack first"` at `0x007b5750`, both byte-recovered). The container-vs-item slot CLASSIFICATION this port uses (`ItemType.Container` instead of retail's bitfield/capacity test) is its own new, narrower approximation — filed separately as AP-168 rather than folded in here. Only `TryBuy`'s single-item Buy path (Items tab's own Buy button, and the Buying tab's "Buy Item") remains WITHOUT a client-side pre-check — the risk/oracle columns below now describe that one remaining case, not both. **EXTENDED 2026-08-09 (Slice 6b) — the same omission now also covers "Buy All".** `ItemInteractionController.TryBuyAll` (the batched-send path `VendorUiController.BuyAllButtonPressed` calls) sends unconditionally too, without porting retail's `pc:204017/204032/204053/204067` affordability/pack-capacity pre-checks for the MULTI-item case either — the same latency-not-correctness tradeoff this row already documents for the single-item path, extended rather than duplicated into a second row; retiring this row should port both the single- and batched-send pre-checks together. **Filed 2026-08-09, Slice 6.3 (buy wire + button).** Retail's `BuySingleItem` (`pc:201661`) performs TWO client-side pre-checks before ever sending `CM_Vendor::Event_Buy`: (a) an affordability check against `this->m_totalValue` (pyreal) or `shopVendorProfile->trade_num - m_last_sale` (alt-currency), showing a LOCAL string via `ECM_UI::SendNotice_DisplayStringInfo` and returning without sending anything on failure (`pc:201686-201717`); (b) a pack/container-capacity pre-check (`pc:201730-201746`) mirroring the server's own check. acdream's `ItemInteractionController.TryBuy` sends unconditionally once the shared use/inventory gate is free — no client-side affordability or capacity check runs before dispatch. Every refused purchase pays a full round-trip (send → server rejects → `UseDone`/`GameEventInventoryServerSaveFailed`) instead of failing instantly and silently client-side. **Swept 2026-08-09 (F4 review fix):** `TryBuy` now also checks whether `sendBuy` actually reached a live, in-world session before marking the reservation dispatched — an orthogonal reservation-leak bug fix (no session ever produced a stray permanent busy-lock), not an affordability/capacity check; this row's scope and residual are unchanged. | `src/AcDream.App/UI/ItemInteractionController.cs` (`TryBuy`) | The research doc's own open question 1 (`docs/research/2026-08-08-slice6-vendor-transactions-research.md`) recommends deferring this: the server is authoritative either way (ACE re-validates both affordability and capacity server-side — `Vendor.BuyItems_ValidateTransaction`, `Vendor.cs:431-571`), so omitting the client pre-check is a LATENCY/UX gap, not a correctness one — a refused purchase still fails cleanly, just one round-trip later than retail. | A player attempting to buy something they cannot afford or have no room for sees the failure arrive after a network round-trip instead of instantly; against a well-behaved ACE server no purchase can succeed that retail's pre-check would have blocked, so no transaction outcome differs — only its latency. Retiring this row means porting `BuySingleItem`'s two pre-check branches (`pc:201686-201746`) into `TryBuy` before dispatch. | `gmVendorUI::BuySingleItem` pc:201661/0x004C2820 (affordability pc:201686-201717, capacity pc:201730-201746); `Vendor.BuyItems_ValidateTransaction` (`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:431-571`); `docs/research/2026-08-08-slice6-vendor-transactions-research.md` §D point 4, Open question 1 | | AP-163 | **REVIEW CORRECTION 2026-08-09 (Opus review of `97cf8738`, finding F1):** this row's ownership discipline is now COMPLETE on both halves, not just the add-time collision guard described below. The retire pass (`OnVendorTransition`'s loop over guids missing from the new `ApproachVendor` snapshot) previously deleted ANY such guid unconditionally — a plain bug, not a documented divergence, since buying a UNIQUE vendor item re-containers that SAME guid into the buyer's own pack (`Player_Commerce.cs:86-108`) BEFORE the post-buy refresh that drops it from the shop's own list arrives; the old retire pass would have stripped the just-purchased item straight back out of the buyer's inventory. **The exact rule now enforced:** each owned guid remembers the vendor id it was registered under (`Dictionary`, guid -> vendorId), and the retire pass calls `ClientObjectTable.Remove` ONLY when the live object's CURRENT `ContainerId` still equals that recorded vendor id; when it differs (or the object is already gone), the tracking entry is dropped silently and the object itself is left completely untouched — the SAME skip-not-clobber discipline the add-time collision guard below already used, now applied symmetrically on the way out. This is a bug fix, not a new divergence, and does not change this row's still-open scope: retail's actual `ClientObjMaintSystem`/`CObjectMaint` collision behavior on a guid collision remains untraced. **Filed 2026-08-09, Slice 6.1 (shop-item materialization).** `VendorShopItemMaterializer` registers each `ApproachVendor` shop item into `ClientObjectTable` keyed by its own server guid. ACE's `UniqueItemsForSale` (`Vendor.cs:34,638`) can list the EXACT guid a player last held (an item sold to this vendor keeps its original guid), so a guid collision against an existing, differently-owned `ClientObjectTable` entry is a real, if rare, possibility. No retail behavior for this exact case was traced (retail's `ClientObjMaintSystem`/`CObjectMaint` guid-keyed registration internals were not decompiled for this pass). acdream's policy is a conscious, conservative default: a guid this materializer did NOT itself add to the table on a previous cycle is treated as owned by something else and is left completely untouched — never overwritten, never later removed by this class. | `src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs` (`OnVendorTransition`'s collision guard) | Skip-not-clobber is the safe default absent a traced retail mechanism: silently reparenting a live entity's or another container's item into the vendor's `ContainerId` would corrupt real ownership state (equipment tracking, burden, radar) for a guid this code does not own, which is strictly worse than a single shop row's status-bar/appraisal projection staying blank. The vendor list itself is unaffected either way — `VendorUiController` reads display fields straight off `VendorShopItem`, never through `ClientObjectTable`. | If retail's actual behavior differs (e.g. it always overwrites, or a real `UniqueItemsForSale` collision is more common than assumed), the one colliding shop row's status-bar/appraisal projection stays stale/blank instead of showing the vendor listing — a narrow, single-row display gap, never a corrupted non-vendor object. Retiring this row requires tracing retail's `ClientObjMaintSystem` registration behavior on a guid collision, which was out of scope for this pass. | No direct retail citation traced this pass — `Vendor.cs:34,638` (`UniqueItemsForSale`, ACE) establishes the collision is POSSIBLE, not what retail does about it; `docs/research/2026-08-08-slice6-vendor-transactions-research.md` (task brief: "study how ACE guids vendor stock and state your collision policy with evidence") | diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index c51a1f27..116ff7ba 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -17,6 +17,7 @@ using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net.Messages; using AcDream.Core.Player; +using AcDream.Core.Properties; using AcDream.Core.Selection; using AcDream.Core.Spells; using AcDream.Runtime; @@ -928,7 +929,14 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory item, inscription), text => - d.Communication.AddText(text, RetailLogTextType.ClientLocal)), + d.Communication.AddText(text, RetailLogTextType.ClientLocal), + // AS4 (gap G6): the SAME LocalPlayerState instance + // characterSheet was built from above — no new state + // path, just another read of its PlayerDescription- + // backed property snapshot. + LocalFactionBits: () => + d.Character.LocalPlayer.Properties.GetInt( + (uint)PropertyInt.Faction1Bits)), Options: new OptionsRuntimeBindings( CommandBus: () => late.Session.Commands, // Tri-state per gmGamePlayUI::UseTime @0x004EA3A0's exact diff --git a/src/AcDream.App/UI/Layout/AppraisalUiController.cs b/src/AcDream.App/UI/Layout/AppraisalUiController.cs index bac95ade..0cf1b56f 100644 --- a/src/AcDream.App/UI/Layout/AppraisalUiController.cs +++ b/src/AcDream.App/UI/Layout/AppraisalUiController.cs @@ -92,6 +92,17 @@ public sealed class AppraisalUiController : IRetainedPanelController /// Template every time, matching retail's own fallback branch. /// private readonly Func _resolveCharacterTitle; + /// + /// AS4 (gap G6): the LOCAL player's own Faction1Bits (PropertyInt 281), + /// resolved PER CALL under the same deferred-Func discipline as + /// — never captured once at mount + /// time. Feeds 's Society + /// row color rule (ground truth §2b row 1). Defaults to 0 (factionless) + /// so a caller that supplies nothing renders the Society row exactly as + /// if the local player belonged to no society, matching retail's own + /// "local has no bits" branch. + /// + private readonly Func _localFactionBits; private readonly SpellExamineComponentTemplateFactory? _spellComponentTemplates; private readonly UiText _spellSchool; private readonly UiText _spellMana; @@ -148,7 +159,8 @@ public sealed class AppraisalUiController : IRetainedPanelController Func>? spellComponents, Func? magicSkill, SpellExamineComponentTemplateFactory? spellComponentTemplates, - Func? resolveCharacterTitle) + Func? resolveCharacterTitle, + Func? localFactionBits) { _layout = layout; _objects = objects; @@ -176,6 +188,7 @@ public sealed class AppraisalUiController : IRetainedPanelController _spellComponents = spellComponents ?? (_ => []); _magicSkill = magicSkill ?? (_ => 0u); _resolveCharacterTitle = resolveCharacterTitle ?? (_ => null); + _localFactionBits = localFactionBits ?? (() => 0); _spellComponentTemplates = spellComponentTemplates; _spellSchool = (UiText)layout.FindElement(SpellSchoolTextId)!; _spellMana = (UiText)layout.FindElement(SpellManaTextId)!; @@ -305,7 +318,8 @@ public sealed class AppraisalUiController : IRetainedPanelController Func>? spellComponents = null, Func? magicSkill = null, SpellExamineComponentTemplateFactory? spellComponentTemplates = null, - Func? resolveCharacterTitle = null) + Func? resolveCharacterTitle = null, + Func? localFactionBits = null) { ArgumentNullException.ThrowIfNull(layout); ArgumentNullException.ThrowIfNull(objects); @@ -358,7 +372,8 @@ public sealed class AppraisalUiController : IRetainedPanelController spellComponents, magicSkill, spellComponentTemplates, - resolveCharacterTitle); + resolveCharacterTitle, + localFactionBits); } /// @@ -822,6 +837,9 @@ public sealed class AppraisalUiController : IRetainedPanelController /// (RefreshCurrentAppraisal) round-trips a /// brand-new response through , so no separate cache /// is needed for a refresh to keep rendering the same armor-level rows. + /// AS4: is only invoked on the character + /// path — the monster path never renders a Society row and has no use + /// for it. /// private void RebuildCreatureStats( AppraiseInfoParser.Parsed appraisal, @@ -836,7 +854,10 @@ public sealed class AppraisalUiController : IRetainedPanelController : Array.Empty()); _creatureExtra?.Rebuild( CreatureAppraisalRows.BuildExtra( - appraisal.Properties, appraisal.ArmorLevels, character)); + appraisal.Properties, + appraisal.ArmorLevels, + character, + character ? _localFactionBits() : 0)); } private void ConfigureScrollableText( diff --git a/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs b/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs index db38926a..ae34464e 100644 --- a/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs +++ b/src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs @@ -3,6 +3,8 @@ using System.Numerics; using AcDream.Content; using AcDream.Core.Items; using AcDream.Core.Net.Messages; +using AcDream.Core.Properties; +using AcDream.Core.Ui; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; @@ -48,6 +50,49 @@ public static class CreatureAppraisalRows private const uint DotResistRating = 0x15Eu; private const uint LifeResistRating = 0x15Fu; + // ── Campaign AS slice AS4: society/allegiance/fellowship + configurable + // extras (G6/G7/G8). Ground truth: docs/research/2026-08-25-campaign-as- + // ground-truth.md §2b rows 1-2 and 8-14. Decomp anchors: CharExamineUI:: + // SetAppraiseInfo @0x004B45F0 — Society @0x004b49a1-0x004b4c24 (color + // rule sourced from the LOCAL player's own Faction1Bits, fetched a few + // lines earlier at @0x004b4971-0x004b4984 via CBaseQualities::InqInt); + // Monarch/Patron/Followers @0x004b4d97-0x004b4f54; configurable extras + // Fellowship @0x004b58be, Arrived in Dereth @0x004b593a, Time in Dereth + // @0x004b59bc, Chess Rank @0x004b5a5b, Fishing Skill @0x004b5afa, Deaths + // @0x004b5b96, Titles Earned @0x004b5c4d — all CHARACTER-path only; the + // monster path (CreatureExamineUI::SetAppraiseInfo @0x004B3FF0) ends + // after its own ratings trailing spacer and never reaches any of this. + private const uint Faction1BitsProperty = (uint)PropertyInt.Faction1Bits; // 281 + private const uint SocietyRankCelestialHandProperty = + (uint)PropertyInt.SocietyRankCelhan; // 287 + private const uint SocietyRankEldrytchWebProperty = + (uint)PropertyInt.SocietyRankEldweb; // 288 + private const uint SocietyRankRadiantBloodProperty = + (uint)PropertyInt.SocietyRankRadblo; // 289 + private const int CelestialHandBit = 0x1; + private const int EldrytchWebBit = 0x2; + private const int RadiantBloodBit = 0x4; + private const int SocietyBitsMask = + CelestialHandBit | EldrytchWebBit | RadiantBloodBit; + + private const uint AllegianceRankProperty = (uint)PropertyInt.AllegianceRank; // 30 + private const uint AllegianceFollowersProperty = + (uint)PropertyInt.AllegianceFollowers; // 35 (Int table) + private const uint MonarchsTitleProperty = + (uint)PropertyString.MonarchsTitle; // 21 (String table) + private const uint PatronsTitleProperty = + (uint)PropertyString.PatronsTitle; // 35 (String table) + + private const uint FellowshipProperty = (uint)PropertyString.Fellowship; // 10 + private const uint DateOfBirthProperty = (uint)PropertyString.DateOfBirth; // 43 + private const uint AgeProperty = (uint)PropertyInt.Age; // 125 + private const uint ChessRankProperty = (uint)PropertyInt.ChessRank; // 181 + private const uint FishingSkillProperty = + (uint)PropertyInt.FakeFishingSkill; // 192 + private const uint NumDeathsProperty = (uint)PropertyInt.NumDeaths; // 43 (Int table) + private const uint NumCharacterTitlesProperty = + (uint)PropertyInt.NumCharacterTitles; // 262 + public static IReadOnlyList Build( AppraiseInfoParser.CreatureProfile profile, bool success) @@ -98,15 +143,26 @@ public static class CreatureAppraisalRows private const int UnenchantableArmorLevel = 9999; /// - /// Port of the armor-level trio + rating rows from + /// Port of the FULL extras list from /// CharExamineUI::SetAppraiseInfo @ 0x004B45F0 (player path) and /// CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0 (monster path, - /// ratings only — no armor-level trio and no unenchantable legend; the - /// monster function ends immediately after its own trailing ratings - /// spacer and never touches base_armor_* or the - /// u"* = Unenchantable" literal). Ground truth: - /// docs/research/2026-08-25-campaign-as-ground-truth.md §2b rows - /// 3-7 + 15, ruling R3 (legend unconditional) and R4 (spacer discipline). + /// ratings only — no society, no allegiance, no armor-level trio, no + /// configurable extras, no unenchantable legend; the monster function + /// ends immediately after its own trailing ratings spacer and never + /// touches any of that). Ground truth: + /// docs/research/2026-08-25-campaign-as-ground-truth.md §2b (the + /// complete row table), rulings R3 (legend unconditional) and R4 (spacer + /// discipline). + /// + /// Row order (character path only): Society (gap G6) → Monarch/Patron/ + /// Followers (gap G7) → armor-level trio → ratings block → configurable + /// extras: Fellowship/Arrived in Dereth/Time in Dereth/Chess Rank/ + /// Fishing Skill/Deaths/Titles Earned (gap G8) → the + /// * = Unenchantable legend, ALWAYS the last row + /// (@0x004b5d7d-@0x004b5ded). No separators exist between the + /// configurable-extra rows in the decomp — only the ratings family gets + /// the leading/trailing spacer treatment described below. + /// /// /// Rating gating/spacer logic: retail's CharExamineUI decompile /// mangles its leading-spacer flag (ebx_13) into unreadable @@ -129,19 +185,38 @@ public static class CreatureAppraisalRows /// /// /// for the CharExamineUI (player) path, - /// which alone emits the armor-level trio and the trailing - /// "* = Unenchantable" legend; for the - /// CreatureExamineUI (monster) path, which never emits either. + /// which alone emits society/allegiance, the armor-level trio, the + /// configurable extras, and the trailing "* = Unenchantable" legend; + /// for the CreatureExamineUI (monster) + /// path, which never emits any of them. + /// + /// + /// The LOCAL player's own Faction1Bits (PropertyInt 281), fetched by + /// retail via CBaseQualities::InqInt on the local qualities + /// object a few lines before the Society row builds + /// (@0x004b4971-@0x004b4984). The composer stays pure — callers source + /// this from whatever seam exposes the local player's live properties + /// (see 's binding). Defaults to 0 + /// (factionless) for callers — mostly monster-path or pre-AS4 tests — + /// that never touch the Society row. /// public static IReadOnlyList BuildExtra( PropertyBundle properties, AppraiseInfoParser.ArmorLevel? armorLevels, - bool character) + bool character, + int localFactionBits = 0) { ArgumentNullException.ThrowIfNull(properties); var rows = new List(); + if (character) + { + AddSocietyRow(rows, properties, localFactionBits); + if (Get(properties, AllegianceRankProperty) >= 1) + AddAllegianceCascade(rows, properties); + } + if (character && armorLevels is { } levels && HasAnyArmorLevel(levels)) { rows.Add(Blank()); @@ -196,6 +271,8 @@ public static class CreatureAppraisalRows if (character) { + AddConfigurableExtras(rows, properties); + rows.Add(new CreatureAppraisalRow( "* = Unenchantable", string.Empty, @@ -205,6 +282,222 @@ public static class CreatureAppraisalRows return rows; } + /// + /// Port of the Society row @0x004b49a1-@0x004b4c24 (gap G6). Gated on + /// PropertyInt 281 Faction1Bits being PRESENT on the response (matches + /// AppraisalProfile::InqInt's found/not-found return, not a + /// value-nonzero test — a slightly more literal reading than the ground + /// truth doc's informal "≠ 0" phrasing). Bit-priority order (Celestial + /// Hand 0x1, then Eldrytch Web 0x2, then — ONLY when neither of those + /// AND Radiant Blood's 0x4 bit is also clear — the unrecognized + /// "???" fallback (data_7af4e8), else Radiant Blood) comes + /// straight from the decompiled if/else-if chain; a target with more + /// than one bit set resolves to whichever bit the chain checks first. + /// The unrecognized branch never sets a color (retail's ebx_3 + /// stays at its zero initializer) — always + /// . + /// + private static void AddSocietyRow( + List rows, + PropertyBundle properties, + int localFactionBits) + { + if (!properties.Ints.TryGetValue(Faction1BitsProperty, out int targetBits)) + return; + + string name; + int rank; + int targetBit; + if ((targetBits & CelestialHandBit) != 0) + { + name = "Celestial Hand"; + rank = Get(properties, SocietyRankCelestialHandProperty); + targetBit = CelestialHandBit; + } + else if ((targetBits & EldrytchWebBit) != 0) + { + name = "Eldrytch Web"; + rank = Get(properties, SocietyRankEldrytchWebProperty); + targetBit = EldrytchWebBit; + } + else if ((targetBits & RadiantBloodBit) == 0) + { + rows.Add(new CreatureAppraisalRow( + "Society:", Unknown, CreatureAppraisalValueStyle.Normal)); + return; + } + else + { + name = "Radiant Blood"; + rank = Get(properties, SocietyRankRadiantBloodProperty); + targetBit = RadiantBloodBit; + } + + rows.Add(new CreatureAppraisalRow( + "Society:", + name + SocietyRankSuffix(rank), + SocietyColor(targetBit, localFactionBits))); + } + + /// + /// Rank-band suffix bounds @0x004b4ab9-@0x004b4b92 — five bands, each + /// inclusive on both ends; outside every band (including rank 0, the + /// value seen when InqInt on the rank property fails) the society + /// name stands alone with no suffix. + /// + private static string SocietyRankSuffix(int rank) => rank switch + { + >= 1 and <= 100 => " ~ Initiate", + >= 101 and <= 300 => " ~ Adept", + >= 301 and <= 600 => " ~ Knight", + >= 601 and <= 1000 => " ~ Lord", + >= 1001 and <= 1500 => " ~ Master", + _ => string.Empty, + }; + + /// + /// The Society color rule: green when the local player shares the + /// TARGET's selected bit (checked first, so a local player with + /// multiple bits set still resolves green if one of them matches); + /// red when the local player has neither of the target's bit but has + /// at least one of the other two society bits; normal (no color) when + /// the local player has no society bits at all. Same + /// InqInt-into-CBaseQualities read for all three branches + /// @0x004b49fd/@0x004b4a49/@0x004b4a8b. + /// + private static CreatureAppraisalValueStyle SocietyColor( + int targetBit, int localFactionBits) + { + if ((localFactionBits & targetBit) != 0) + return CreatureAppraisalValueStyle.Positive; + if ((localFactionBits & (SocietyBitsMask & ~targetBit)) != 0) + return CreatureAppraisalValueStyle.Negative; + return CreatureAppraisalValueStyle.Normal; + } + + /// + /// Port of the Monarch/Patron/Followers cascade @0x004b4d97-@0x004b4f54 + /// (gap G7), gated by the caller on AllegianceRank >= 1 (the SAME + /// var_108 InqInt read retail's header AllegianceName binding + /// already consumes — see ). Four arms, tested in retail's exact + /// order: MonarchsTitle (Str 21) absent → follower-count row; present + + /// PatronsTitle (Str 35) absent → Monarch-only row; both present and + /// EQUAL (ordinal — server-composed strings, no case-folding in the + /// decomp) → one combined row; both present and different → two rows. + /// + private static void AddAllegianceCascade( + List rows, PropertyBundle properties) + { + if (!properties.Strings.TryGetValue( + MonarchsTitleProperty, out string? monarchsTitle)) + { + int followers = Get(properties, AllegianceFollowersProperty); + if (followers < 0) + followers = 0; + string unit = followers == 1 ? "Follower" : "Followers"; + rows.Add(new CreatureAppraisalRow( + "Alleg. Monarch:", + $"{Number(followers)} {unit}", + CreatureAppraisalValueStyle.Normal)); + return; + } + + if (!properties.Strings.TryGetValue( + PatronsTitleProperty, out string? patronsTitle)) + { + rows.Add(new CreatureAppraisalRow( + "Monarch:", monarchsTitle, CreatureAppraisalValueStyle.Normal)); + return; + } + + if (string.Equals(monarchsTitle, patronsTitle, StringComparison.Ordinal)) + { + rows.Add(new CreatureAppraisalRow( + "Monarch/Patron:", + monarchsTitle, + CreatureAppraisalValueStyle.Normal)); + return; + } + + rows.Add(new CreatureAppraisalRow( + "Monarch:", monarchsTitle, CreatureAppraisalValueStyle.Normal)); + rows.Add(new CreatureAppraisalRow( + "Patron:", patronsTitle, CreatureAppraisalValueStyle.Normal)); + } + + /// + /// Port of the seven configurable-extras rows (gap G8, ground truth §2b + /// rows 8-14): Fellowship @0x004b58be, Arrived in Dereth @0x004b593a, + /// Time in Dereth @0x004b59bc, Chess Rank @0x004b5a5b, Fishing Skill + /// @0x004b5afa, Deaths @0x004b5b96, Titles Earned @0x004b5c4d. Each row + /// is independently gated on its OWN property being present on the + /// response — ACE already strips these per the TARGET's own visibility + /// options before the response is sent + /// (AppraiseInfo.cs:352-366), so no client-side option logic or + /// success gating belongs here. No spacers separate these rows in the + /// decomp. Time in Dereth reuses the ALREADY-PORTED + /// — the exact same + /// ClientUISystem::DeltaTimeToString @0x00565E10 the decomp calls + /// here (@0x004b59e0) — rather than porting a duplicate. + /// + private static void AddConfigurableExtras( + List rows, PropertyBundle properties) + { + if (properties.Strings.TryGetValue( + FellowshipProperty, out string? fellowship)) + { + rows.Add(new CreatureAppraisalRow( + "Fellowship:", fellowship, CreatureAppraisalValueStyle.Normal)); + } + + if (properties.Strings.TryGetValue( + DateOfBirthProperty, out string? arrived)) + { + rows.Add(new CreatureAppraisalRow( + "Arrived in Dereth:", + arrived, + CreatureAppraisalValueStyle.Normal)); + } + + if (properties.Ints.TryGetValue(AgeProperty, out int ageSeconds)) + { + rows.Add(new CreatureAppraisalRow( + "Time in Dereth:", + RetailDurationText.Format(ageSeconds), + CreatureAppraisalValueStyle.Normal)); + } + + if (properties.Ints.TryGetValue(ChessRankProperty, out int chessRank)) + { + rows.Add(new CreatureAppraisalRow( + "Chess Rank:", Number(chessRank), CreatureAppraisalValueStyle.Normal)); + } + + if (properties.Ints.TryGetValue(FishingSkillProperty, out int fishingSkill)) + { + rows.Add(new CreatureAppraisalRow( + "Fishing Skill:", + Number(fishingSkill), + CreatureAppraisalValueStyle.Normal)); + } + + if (properties.Ints.TryGetValue(NumDeathsProperty, out int deaths)) + { + rows.Add(new CreatureAppraisalRow( + "Deaths:", + deaths <= 0 ? "Has never died" : Number(deaths), + CreatureAppraisalValueStyle.Normal)); + } + + if (properties.Ints.TryGetValue( + NumCharacterTitlesProperty, out int titles)) + { + rows.Add(new CreatureAppraisalRow( + "Titles Earned:", Number(titles), CreatureAppraisalValueStyle.Normal)); + } + } + private static bool HasAnyArmorLevel(AppraiseInfoParser.ArmorLevel levels) => levels.Head > 0 || levels.Chest > 0 || levels.Abdomen > 0 || levels.UpperArm > 0 || levels.LowerArm > 0 || levels.Hand > 0 diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 0e9cdc48..f8b77788 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -411,7 +411,15 @@ public sealed record ConfirmationRuntimeBindings( public sealed record AppraisalRuntimeBindings( Func PlayerName, Action SendSetInscription, - Action DisplaySystemMessage); + Action DisplaySystemMessage, + // Campaign AS slice AS4 (gap G6): the LOCAL player's own Faction1Bits + // (PropertyInt 281), feeding the examination window's Society row color + // rule (CreatureAppraisalRows.BuildExtra). Sourced from the SAME + // LocalPlayerState reference CharacterSheetProvider is built from + // (d.Character.LocalPlayer in InteractionRetainedUiComposition) — no new + // state path, just another read of the existing PlayerDescription-backed + // property snapshot. + Func LocalFactionBits); public sealed record VendorRuntimeBindings( VendorState State, @@ -2147,7 +2155,8 @@ public sealed class RetailUiRuntime : IDisposable spellComponents: _bindings.Magic.SpellComponents, magicSkill: _bindings.Magic.MagicSkill, spellComponentTemplates: spellComponentTemplates, - resolveCharacterTitle: ResolveCharacterTitle); + resolveCharacterTitle: ResolveCharacterTitle, + localFactionBits: _bindings.Appraisal.LocalFactionBits); if (controller is null) { Console.WriteLine( diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs index 73366bb6..7eb2f7a2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs @@ -891,14 +891,13 @@ public sealed class AppraisalUiControllerTests interaction.ExamineSelectedOrEnterMode(ObjectId); var properties = new PropertyBundle(); properties.Strings[5u] = "Template"; - var armorLevels = new AppraiseInfoParser.ArmorLevel( + var firstArmorLevels = new AppraiseInfoParser.ArmorLevel( Head: 50, Chest: 60, Abdomen: 70, UpperArm: 80, LowerArm: 90, Hand: 100, UpperLeg: 110, LowerLeg: 120, Foot: 130); - AppraiseInfoParser.Parsed appraisal = Parsed( - properties, MinimalCreatureProfile(), armorLevels: armorLevels); - Assert.True(controller.Apply(appraisal)); + Assert.True(controller.Apply(Parsed( + properties, MinimalCreatureProfile(), armorLevels: firstArmorLevels))); controller.OnShown(); int sentBeforeRefresh = sent.Count; @@ -907,20 +906,142 @@ public sealed class AppraisalUiControllerTests // The 0.75 s combat refresh fired exactly one fresh wire request. Assert.Equal(sentBeforeRefresh + 1, sent.Count); - // The refreshed response is a brand-new Parsed value coming back - // through Apply — AppraiseInfoParser always parses ArmorLevels when - // the flag is set, so nothing needs to be cached client-side for - // the re-applied response to keep rendering the same AL rows. - Assert.True(controller.Apply(appraisal)); + // AS3 NIT 11: the refresh brings back a GENUINELY DIFFERENT response + // (e.g. gear swapped mid-fight) — proving the extras list re-renders + // from the fresh data rather than replaying a cached copy of the + // first response. + var secondArmorLevels = new AppraiseInfoParser.ArmorLevel( + Head: 51, Chest: 61, Abdomen: 71, + UpperArm: 81, LowerArm: 91, Hand: 101, + UpperLeg: 111, LowerLeg: 121, Foot: 131); + Assert.True(controller.Apply(Parsed( + properties, MinimalCreatureProfile(), armorLevels: secondArmorLevels))); + + UiItemList extraAfterSecond = CreatureExtraList(layout); + Assert.Equal(5, extraAfterSecond.GetNumUIItems()); + Assert.Equal( + ("Head/Chest/Groin", "AL: 51/61/71"), ExtraRow(extraAfterSecond, 1)); + Assert.Equal( + ("Bicep/Wrist/Hand", "AL: 81/91/101"), ExtraRow(extraAfterSecond, 2)); + Assert.Equal( + ("Thigh/Shin/Foot", "AL: 111/121/131"), ExtraRow(extraAfterSecond, 3)); + + // A THIRD response carrying no ArmorLevels blob at all (e.g. a + // response that simply didn't set the flag) clears the trio + // entirely rather than leaving the second response's rows stuck + // on screen. + Assert.True(controller.Apply(Parsed( + properties, MinimalCreatureProfile(), armorLevels: null))); + + UiItemList extraAfterThird = CreatureExtraList(layout); + Assert.Equal(1, extraAfterThird.GetNumUIItems()); + Assert.Equal( + ("* = Unenchantable", string.Empty), ExtraRow(extraAfterThird, 0)); + } + + // ── Campaign AS slice AS4: society/allegiance/fellowship + configurable + // extras (G6/G7/G8). Real LayoutDesc/template binding through the + // localFactionBits seam (AppraisalRuntimeBindings.LocalFactionBits in + // production), matching the AS2/AS3 controller-level pattern. + + [Fact] + public void CharacterResponse_SocietyAllegianceAndFellowshipRenderThroughRealBinding() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Dww", + Type = ItemType.Creature, + }); + using var interaction = NewInteraction(objects, []); + var templates = new CreatureAppraisalRowTemplateFactory( + FixtureLoader.LoadExaminationRowTemplateInfos(), + NoTexture, + defaultFont: null); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { }, + templates, + // The seam AppraisalRuntimeBindings.LocalFactionBits threads in + // production: the local player shares the target's Celestial + // Hand bit. + localFactionBits: () => 0x1)!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + var properties = new PropertyBundle(); + properties.Strings[5u] = "Template"; // Character-view marker + properties.Ints[281u] = 0x1; // Faction1Bits: Celestial Hand + properties.Ints[287u] = 50; // society rank -> Initiate band + properties.Ints[30u] = 5; // AllegianceRank >= 1 + properties.Strings[21u] = "Monarch Title"; // no PatronsTitle -> Monarch-only row + properties.Strings[10u] = "Fellows"; + + Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile()))); + Assert.Equal(AppraisalView.Character, controller.ActiveView); UiItemList extra = CreatureExtraList(layout); - Assert.Equal(5, extra.GetNumUIItems()); + Assert.Equal(4, extra.GetNumUIItems()); Assert.Equal( - ("Head/Chest/Groin", "AL: 50/60/70"), ExtraRow(extra, 1)); + ("Society:", "Celestial Hand ~ Initiate"), ExtraRow(extra, 0)); + Assert.Equal(("Monarch:", "Monarch Title"), ExtraRow(extra, 1)); + Assert.Equal(("Fellowship:", "Fellows"), ExtraRow(extra, 2)); Assert.Equal( - ("Bicep/Wrist/Hand", "AL: 80/90/100"), ExtraRow(extra, 2)); + ("* = Unenchantable", string.Empty), ExtraRow(extra, 3)); + } + + [Fact] + public void CharacterResponse_LocalFactionBitsDefaultsToFactionlessWhenBindingSuppliesNone() + { + // No localFactionBits argument -> AppraisalUiController's own + // fallback (() => 0), exactly like a caller that never wires the + // binding (matches R7-style defensive defaults elsewhere in this + // controller). + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Dww", + Type = ItemType.Creature, + }); + using var interaction = NewInteraction(objects, []); + var templates = new CreatureAppraisalRowTemplateFactory( + FixtureLoader.LoadExaminationRowTemplateInfos(), + NoTexture, + defaultFont: null); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { }, + templates)!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + var properties = new PropertyBundle(); + properties.Strings[5u] = "Template"; + properties.Ints[281u] = 0x1; // Faction1Bits: Celestial Hand + + Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile()))); + + UiItemList extra = CreatureExtraList(layout); + // Row still renders (Society doesn't gate on the local player); it + // just gets no color because the local player has no matching bit. + // No rank property was set, so there is no band suffix (rank 0 is + // outside every band). Assert.Equal( - ("Thigh/Shin/Foot", "AL: 110/120/130"), ExtraRow(extra, 3)); + ("Society:", "Celestial Hand"), ExtraRow(extra, 0)); } [Fact] @@ -953,14 +1074,24 @@ public sealed class AppraisalUiControllerTests interaction.ExamineSelectedOrEnterMode(ObjectId); // No String 5 / Int 261 marker -> monster path. ArmorLevels present // on the wire (a real ACE response always carries them for a - // successful non-player target too) must still be ignored here. + // successful non-player target too) must still be ignored here, and + // so must AS4's society/allegiance/configurable-extras properties — + // a real ACE response can carry those for a non-attackable monster + // target too (ground truth §3), but CreatureExamineUI:: + // SetAppraiseInfo @0x004B3FF0 never reads any of them. var armorLevels = new AppraiseInfoParser.ArmorLevel( Head: 100, Chest: 110, Abdomen: 120, UpperArm: 130, LowerArm: 140, Hand: 150, UpperLeg: 160, LowerLeg: 170, Foot: 180); + var properties = new PropertyBundle(); + properties.Ints[281u] = 0x1; // Faction1Bits + properties.Ints[30u] = 5; // AllegianceRank + properties.Strings[21u] = "Monarch Title"; + properties.Strings[10u] = "Fellows"; + properties.Ints[43u] = 2; // NumDeaths Assert.True(controller.Apply(Parsed( - new PropertyBundle(), + properties, MinimalCreatureProfile(), armorLevels: armorLevels))); Assert.Equal(AppraisalView.Creature, controller.ActiveView); @@ -1389,7 +1520,8 @@ public sealed class AppraisalUiControllerTests Func>? spellComponents = null, Func? magicSkill = null, SpellExamineComponentTemplateFactory? spellComponentTemplates = null, - Func? resolveCharacterTitle = null) + Func? resolveCharacterTitle = null, + Func? localFactionBits = null) => AppraisalUiController.Bind( layout, objects, @@ -1410,7 +1542,8 @@ public sealed class AppraisalUiControllerTests spellComponents, magicSkill, spellComponentTemplates, - resolveCharacterTitle); + resolveCharacterTitle, + localFactionBits); private static ItemInteractionController NewInteraction( ClientObjectTable objects, @@ -1434,9 +1567,12 @@ public sealed class AppraisalUiControllerTests AppraiseInfoParser.ArmorLevel? armorLevels = null) => new( Guid: guid, - Flags: creature is null + Flags: (creature is null ? AppraiseInfoParser.IdentifyResponseFlags.IntStatsTable - : AppraiseInfoParser.IdentifyResponseFlags.CreatureProfile, + : AppraiseInfoParser.IdentifyResponseFlags.CreatureProfile) + | (armorLevels is null + ? AppraiseInfoParser.IdentifyResponseFlags.None + : AppraiseInfoParser.IdentifyResponseFlags.ArmorLevels), Success: success, Properties: properties, SpellBook: [], diff --git a/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs b/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs index cca7ea3a..e6ffa30b 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CreatureAppraisalRowsTests.cs @@ -2,6 +2,7 @@ using AcDream.App.UI; using AcDream.App.UI.Layout; using AcDream.Core.Items; using AcDream.Core.Net.Messages; +using AcDream.Core.Ui; namespace AcDream.App.Tests.UI.Layout; @@ -261,6 +262,334 @@ public sealed class CreatureAppraisalRowsTests Assert.Equal(("* = Unenchantable", string.Empty), (only.Label, only.Value)); } + // ── Campaign AS slice AS4: society/allegiance/fellowship + configurable + // extras (G6/G7/G8). Ground truth: docs/research/2026-08-25-campaign-as- + // ground-truth.md §2b rows 1-2 and 8-14. Decomp anchors: Society + // @0x004b49a1-@0x004b4c24, Monarch/Patron/Followers + // @0x004b4d97-@0x004b4f54, configurable extras + // @0x004b58be-@0x004b5c4d. + + [Fact] + public void SocietyRowAbsentWhenFaction1BitsPropertyNotPresent() + { + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + new PropertyBundle(), armorLevels: null, character: true); + + Assert.DoesNotContain(rows, r => r.Label == "Society:"); + } + + [Theory] + [InlineData(0x1, 287u, "Celestial Hand")] + [InlineData(0x2, 288u, "Eldrytch Web")] + [InlineData(0x4, 289u, "Radiant Blood")] + public void SocietyRowSelectsNameAndRankPropertyPerTargetBit( + int targetBit, uint rankPropertyId, string expectedName) + { + var properties = new PropertyBundle(); + properties.Ints[281u] = targetBit; + properties.Ints[rankPropertyId] = 50; // Initiate band + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true); + + CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Society:"); + Assert.Equal($"{expectedName} ~ Initiate", row.Value); + } + + [Theory] + [InlineData(0x8)] + [InlineData(0x10)] + public void SocietyRowFallsBackToUnrecognizedForUnknownBitCombinations(int bits) + { + var properties = new PropertyBundle(); + properties.Ints[281u] = bits; + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true, localFactionBits: 0x1); + + CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Society:"); + Assert.Equal("???", row.Value); + // Retail's ebx_3 color accumulator never gets touched on this branch. + Assert.Equal(CreatureAppraisalValueStyle.Normal, row.Style); + } + + [Theory] + [InlineData(0, "")] + [InlineData(1, " ~ Initiate")] + [InlineData(100, " ~ Initiate")] + [InlineData(101, " ~ Adept")] + [InlineData(300, " ~ Adept")] + [InlineData(301, " ~ Knight")] + [InlineData(600, " ~ Knight")] + [InlineData(601, " ~ Lord")] + [InlineData(1000, " ~ Lord")] + [InlineData(1001, " ~ Master")] + [InlineData(1500, " ~ Master")] + [InlineData(1501, "")] + public void SocietyRankBandSuffixMatchesRetailInclusiveBoundaries( + int rank, string expectedSuffix) + { + var properties = new PropertyBundle(); + properties.Ints[281u] = 0x1; // Celestial Hand + properties.Ints[287u] = rank; + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true); + + CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Society:"); + Assert.Equal("Celestial Hand" + expectedSuffix, row.Value); + } + + [Theory] + [InlineData(0x1, 0x1, CreatureAppraisalValueStyle.Positive)] + [InlineData(0x1, 0x2, CreatureAppraisalValueStyle.Negative)] + [InlineData(0x1, 0x4, CreatureAppraisalValueStyle.Negative)] + [InlineData(0x1, 0x0, CreatureAppraisalValueStyle.Normal)] + [InlineData(0x2, 0x2, CreatureAppraisalValueStyle.Positive)] + [InlineData(0x2, 0x1, CreatureAppraisalValueStyle.Negative)] + [InlineData(0x4, 0x4, CreatureAppraisalValueStyle.Positive)] + [InlineData(0x4, 0x2, CreatureAppraisalValueStyle.Negative)] + public void SocietyColorReflectsLocalPlayerFactionBitsAgainstTarget( + int targetBit, int localFactionBits, CreatureAppraisalValueStyle expected) + { + var properties = new PropertyBundle(); + properties.Ints[281u] = targetBit; + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true, localFactionBits); + + CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Society:"); + Assert.Equal(expected, row.Style); + } + + [Fact] + public void SocietyColorPrioritizesSameBitMatchEvenWhenLocalHasOtherBitsToo() + { + var properties = new PropertyBundle(); + properties.Ints[281u] = 0x1; + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true, localFactionBits: 0x1 | 0x2); + + CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Society:"); + Assert.Equal(CreatureAppraisalValueStyle.Positive, row.Style); + } + + [Fact] + public void AllegianceCascadeAbsentWhenAllegianceRankBelowOne() + { + var properties = new PropertyBundle(); + properties.Ints[30u] = 0; + properties.Strings[21u] = "Should Not Appear"; + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true); + + Assert.DoesNotContain(rows, r => r.Label is "Monarch:" or "Patron:" + or "Monarch/Patron:" or "Alleg. Monarch:"); + } + + [Theory] + [InlineData(0, "0 Followers")] + [InlineData(1, "1 Follower")] + [InlineData(2, "2 Followers")] + [InlineData(-5, "0 Followers")] + public void AllegianceCascadeShowsClampedFollowerCountWhenMonarchsTitleAbsent( + int followers, string expected) + { + var properties = new PropertyBundle(); + properties.Ints[30u] = 1; + properties.Ints[35u] = followers; + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true); + + CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Alleg. Monarch:"); + Assert.Equal(expected, row.Value); + } + + [Fact] + public void AllegianceCascadeShowsMonarchOnlyWhenPatronsTitleAbsent() + { + var properties = new PropertyBundle(); + properties.Ints[30u] = 1; + properties.Strings[21u] = "Baroness Aluvia"; + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true); + + Assert.Equal( + "Baroness Aluvia", Assert.Single(rows, r => r.Label == "Monarch:").Value); + Assert.DoesNotContain(rows, r => r.Label is "Patron:" or "Monarch/Patron:"); + } + + [Fact] + public void AllegianceCascadeCombinesMonarchAndPatronWhenTitlesAreOrdinallyEqual() + { + var properties = new PropertyBundle(); + properties.Ints[30u] = 1; + properties.Strings[21u] = "Same Title"; + properties.Strings[35u] = "Same Title"; + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true); + + Assert.Equal( + "Same Title", Assert.Single(rows, r => r.Label == "Monarch/Patron:").Value); + Assert.DoesNotContain(rows, r => r.Label is "Monarch:" or "Patron:"); + } + + [Fact] + public void AllegianceCascadeSplitsMonarchAndPatronWhenTitlesDiffer() + { + var properties = new PropertyBundle(); + properties.Ints[30u] = 1; + properties.Strings[21u] = "Monarch Title"; + properties.Strings[35u] = "Patron Title"; + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true); + + Assert.Equal( + "Monarch Title", Assert.Single(rows, r => r.Label == "Monarch:").Value); + Assert.Equal( + "Patron Title", Assert.Single(rows, r => r.Label == "Patron:").Value); + Assert.DoesNotContain(rows, r => r.Label == "Monarch/Patron:"); + } + + [Fact] + public void ConfigurableExtrasEachAppearOnlyWhenTheirOwnPropertyIsPresent() + { + var properties = new PropertyBundle(); + properties.Strings[10u] = "The Fellows"; + properties.Strings[43u] = "1/1/2023"; // DateOfBirth (String table) + properties.Ints[125u] = 90; // Age, seconds + properties.Ints[181u] = 7; + properties.Ints[192u] = 42; + properties.Ints[262u] = 3; + // Int 43 (NumDeaths) deliberately absent — separate Int-table id + // from the String-table DateOfBirth id above; must not leak a row. + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true); + + Assert.Equal( + "The Fellows", Assert.Single(rows, r => r.Label == "Fellowship:").Value); + Assert.Equal( + "1/1/2023", + Assert.Single(rows, r => r.Label == "Arrived in Dereth:").Value); + Assert.Equal( + RetailDurationText.Format(90), + Assert.Single(rows, r => r.Label == "Time in Dereth:").Value); + Assert.Equal("7", Assert.Single(rows, r => r.Label == "Chess Rank:").Value); + Assert.Equal("42", Assert.Single(rows, r => r.Label == "Fishing Skill:").Value); + Assert.Equal("3", Assert.Single(rows, r => r.Label == "Titles Earned:").Value); + Assert.DoesNotContain(rows, r => r.Label == "Deaths:"); + } + + [Fact] + public void ConfigurableExtrasAllAbsentLeavesOnlyTheLegend() + { + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + new PropertyBundle(), armorLevels: null, character: true); + + CreatureAppraisalRow only = Assert.Single(rows); + Assert.Equal("* = Unenchantable", only.Label); + } + + [Theory] + [InlineData(0, "Has never died")] + [InlineData(-3, "Has never died")] + [InlineData(1, "1")] + [InlineData(5, "5")] + public void DeathsRowShowsHasNeverDiedAtOrBelowZeroButKeepsTheSameLabel( + int deaths, string expected) + { + var properties = new PropertyBundle(); + properties.Ints[43u] = deaths; // NumDeaths (Int table) + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: true); + + CreatureAppraisalRow row = Assert.Single(rows, r => r.Label == "Deaths:"); + Assert.Equal(expected, row.Value); + } + + [Fact] + public void MonsterPathNeverGainsSocietyAllegianceOrConfigurableExtraRows() + { + var properties = new PropertyBundle(); + properties.Ints[281u] = 0x1; + properties.Ints[30u] = 5; + properties.Strings[21u] = "Monarch Title"; + properties.Strings[10u] = "Fellows"; + properties.Strings[43u] = "1/1/2023"; + properties.Ints[125u] = 90; + properties.Ints[181u] = 7; + properties.Ints[192u] = 42; + properties.Ints[43u] = 2; + properties.Ints[262u] = 3; + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, armorLevels: null, character: false, localFactionBits: 0x1); + + Assert.DoesNotContain(rows, r => r.Label == "Society:"); + Assert.DoesNotContain(rows, r => r.Label is "Monarch:" or "Patron:" + or "Monarch/Patron:" or "Alleg. Monarch:"); + Assert.DoesNotContain(rows, r => r.Label == "Fellowship:"); + Assert.DoesNotContain(rows, r => r.Label == "Arrived in Dereth:"); + Assert.DoesNotContain(rows, r => r.Label == "Time in Dereth:"); + Assert.DoesNotContain(rows, r => r.Label == "Chess Rank:"); + Assert.DoesNotContain(rows, r => r.Label == "Fishing Skill:"); + Assert.DoesNotContain(rows, r => r.Label == "Deaths:"); + Assert.DoesNotContain(rows, r => r.Label == "Titles Earned:"); + Assert.DoesNotContain(rows, r => r.Label == "* = Unenchantable"); + } + + [Fact] + public void CompleteCharacterExtrasOrderingMatchesRetailRowSequence() + { + var properties = new PropertyBundle(); + // Society: local shares the target's Celestial Hand bit -> green. + properties.Ints[281u] = 0x1; + properties.Ints[287u] = 50; + // Allegiance cascade: monarch/patron present and different. + properties.Ints[30u] = 5; + properties.Strings[21u] = "Monarch Title"; + properties.Strings[35u] = "Patron Title"; + // Ratings: only the Dmg/CritDmg family fires. + properties.Ints[0x133u] = 10; + // Configurable extras: only Fellowship and Deaths present. + properties.Strings[10u] = "Fellows"; + properties.Ints[43u] = 0; + var levels = new AppraiseInfoParser.ArmorLevel( + Head: 1, Chest: 0, Abdomen: 0, + UpperArm: 0, LowerArm: 0, Hand: 0, + UpperLeg: 0, LowerLeg: 0, Foot: 0); + + IReadOnlyList rows = CreatureAppraisalRows.BuildExtra( + properties, levels, character: true, localFactionBits: 0x1); + + Assert.Equal( + [ + "Society:", + "Monarch:", + "Patron:", + "", // AL trio leading spacer + "Head/Chest/Groin", + "Bicep/Wrist/Hand", + "Thigh/Shin/Foot", + "", // ratings leading spacer + "Dmg/CritDmg", + "", // ratings trailing spacer + "Fellowship:", + "Deaths:", + "* = Unenchantable", + ], + rows.Select(r => r.Label)); + Assert.Equal(CreatureAppraisalValueStyle.Positive, rows[0].Style); + } + [Fact] public void AuthoredRowTemplateCarriesRetailOverlappingLabelValueGeometry() { From bf8f5b70a9b01dc07362f4716edb05fa123ca405 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 09:58:47 +0200 Subject: [PATCH 70/89] =?UTF-8?q?docs(AS):=20AS4=20fix=20round=20=E2=80=94?= =?UTF-8?q?=20oracle=20corrections=20(D1),=20gate=20carry-note=20(D2),=20r?= =?UTF-8?q?ecord=20fixes=20(D3);=20file=20#442?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AS4 dual-lens review approved the port as exact and required a docs-only fix round: - Ground truth §2b row 1: the Society gate is PRESENCE of Int 281 (AppraisalProfile::InqInt @0x005B3830 returns found/not-found), not value!=0; the color rule is same-bit-first (@0x004b49fd/@0x004b4a49/ @0x004b4a8b) so a multi-bit local player still resolves green on a match; the ??? arm precedes the Radiant Blood test. - Ruling R5 corrected: the row model carries CreatureAppraisalValueStyle but ResolveColor is a deliberate no-op until AP-110's FontInfo-list residual lands — the Society green/red is model-only and invisible at the connected gate; AS6's script must not gate on row colors. - Ledger: AS4 land 4ade9b04; true full-solution hermetic count is 15,528 (the AS4 commit body's 15,410 was a mis-report; the review re-ran and reconciled 15,483 + 45 new = 15,528). - #442 filed: pre-existing parallel-load flake in the shadow-caster zero-allocation pin, surfaced by the review's full-solution run; isolation evidence recorded; unrelated to Campaign AS. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 18 ++++++++++++++++++ ...2026-08-25-assess-window-parity-campaign.md | 2 +- .../2026-08-25-campaign-as-ground-truth.md | 11 ++++++++--- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 894b7613..7db802e7 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,24 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #442 — Flake: DirectionalShadowCasterFrameTests.WarmDenseChangedFrames_AllocateZeroAndReadNoSceneRecords fails intermittently under full parallel suite load + +**Status:** OPEN. +**Component:** rendering tests / zero-allocation pins. +**Filed:** 2026-08-25 (surfaced by the Campaign AS AS4 Opus review's full-solution run; unrelated to AS4 — the slice touches no rendering code). + +A `GC.GetAllocatedBytesForCurrentThread()` zero-allocation assertion in +`tests/AcDream.App.Tests/Rendering/DirectionalShadowCasterFrameTests.cs` +trips intermittently under parallel load — same class as the known +`RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate` +flake (full-solution parallel load only) and #439's headroom family. +Review isolation evidence (Release): alone (15-test class) passes; App +hermetic lane run 1 = 6,252/1 failed, run 2 (same command) = 6,253/0. +Last touched by `d78ce100` (a render commit). Candidate fixes when picked +up: the established `Lane=Timing` quarantine per `docs/release-gate.md` +(do NOT chase individually), or a warmed re-measure loop like other +zero-alloc pins use. Do not weaken the assertion itself without measuring. + ## #441 — Death return to lifestone: stuck in portal once; arrived once with world not ready (missing doors/portals) **Status:** OPEN (intermittent — did not reproduce under probes 2026-08-24). diff --git a/docs/plans/2026-08-25-assess-window-parity-campaign.md b/docs/plans/2026-08-25-assess-window-parity-campaign.md index 4acc57cb..6dd8763d 100644 --- a/docs/plans/2026-08-25-assess-window-parity-campaign.md +++ b/docs/plans/2026-08-25-assess-window-parity-campaign.md @@ -139,7 +139,7 @@ round → narrow re-review → REVIEW-CLOSED. | AS1 | **DONE 2026-08-25** | (docs commit) | 3-agent research; ground-truth doc committed | | AS2 | **REVIEW-CLOSED 2026-08-25** | `f8a22589` / `cc5290af` | port exact per dual-lens review; 6 findings (docs/test/refactor) fixed; +AD-114 (animated paperdoll), +AD-115 (title clear-vs-stale), PK bits promoted to `PublicWeenieFlags`; carried follow-up chip: 3 more Core PK-bit copies | | AS3 | **REVIEW-CLOSED 2026-08-25** | `1616cd3d` (no fix round) | APPROVE first pass — trio/legend/monster-path exact by offset-level decomp verification; R3 flattening theory disproven at source; ratings adjudication: pre-AS3 code already retail-exact. 5 NITs: 12 (legend-order comment), 11 (stronger refresh test) fold into AS4; 14/15 done in the close commit; 13 (geometry-keyed test helper) noted | -| AS4 | pending chip-session quiesce | — | AS4 also carries AS3 NITs 11+12 and the AP-110 narrowing | +| AS4 | fix round (docs-only) applied, re-review pending | `4ade9b04` + docs fix commit | port exact per dual-lens review (presence-gate adjudicated FOR the implementer at `InqInt @0x005B3830`; Time-in-Dereth = pre-existing `RetailDurationText @0x00565E10` port, correct reuse); fix round was oracle-doc corrections + records only, NO code change. True full-solution hermetic count 15,528 (the commit's 15,410 was a mis-report). **AS6 carry-note: the Society green/red colorIdx is MODEL-ONLY (ResolveColor no-op pending AP-110 FontInfo residual) — the gate script must NOT gate on row colors.** Pre-existing parallel-load flake surfaced (shadow-caster zero-alloc pin) — filed in ISSUES, unrelated to AS4 | | AS4 | pending AS3 review-close | — | | | AS5 | pending AS4 review-close | — | | | AS6 | pending AS5 review-close | — | | diff --git a/docs/research/2026-08-25-campaign-as-ground-truth.md b/docs/research/2026-08-25-campaign-as-ground-truth.md index 6fc952fc..2b09c667 100644 --- a/docs/research/2026-08-25-campaign-as-ground-truth.md +++ b/docs/research/2026-08-25-campaign-as-ground-truth.md @@ -84,7 +84,7 @@ Literals: `"%d"` @0x794344/0x7A0184; `"*%d"` @0x7B110C; `u"???"` @0x7B0F34; | # | Gate | Label | Value | Color | |---|---|---|---|---| -| 1 | Int 281 Faction1Bits ≠ 0 | `Society:` | society name (+` ~ ` band): bit1→"Celestial Hand"+Int 287, bit2→"Eldrytch Web"+Int 288, bit4→"Radiant Blood"+Int 289, none→`???`. Bands: 1–100 Initiate, 101–300 Adept, 301–600 Knight, 601–1000 Lord, 1001–1500 Master; outside bands → name alone | vs LOCAL player's Faction1Bits (`InqInt 0x119` on own qualities): same → 1 (green), different faction → 2 (red), local none → 0 | +| 1 | **PRESENCE of Int 281** Faction1Bits — `AppraisalProfile::InqInt @0x005B3830` returns found/not-found, NOT value≠0 (AS4-review adjudicated); present-and-zero → `Society: ???` | `Society:` | society name (+` ~ ` band): bit1→"Celestial Hand"+Int 287, bit2→"Eldrytch Web"+Int 288, then the `???` arm, THEN bit4→"Radiant Blood"+Int 289 (retail's odd test order); unrecognized→`???`. Bands: 1–100 Initiate, 101–300 Adept, 301–600 Knight, 601–1000 Lord, 1001–1500 Master; outside bands → name alone | vs LOCAL player's Faction1Bits, SAME-BIT-FIRST (`@0x004b49fd/@0x004b4a49/@0x004b4a8b`): local carries the target's bit → 1 (green) even when local also carries other bits; else local carries any other society bit → 2 (red); else 0 | | 2 | Int 30 ≥ 1 | allegiance rows | Str 21 MonarchsTitle absent → `Alleg. Monarch:` + `%d Follower`/`%d Followers` (Int 35, clamp ≥0). Str 21 present, Str 35 PatronsTitle absent → `Monarch:`+Str21. Both present, equal → one row `Monarch/Patron:`. Different → two rows `Monarch:` / `Patron:` | 0 | | 3 | any of 9 AL > 0 | spacer, then `Head/Chest/Groin`, `Bicep/Wrist/Hand`, `Thigh/Shin/Foot` | per part `"%d"`, or `"*%d"` with (value−9999) when ≥9999 (unenchantable sentinel); value cell = `"AL: %s/%s/%s"`. Nine dwords in order head, chest, groin(=Abdomen), bicep(=UpperArm), wrist(=LowerArm), hand, thigh(=UpperLeg), shin(=LowerLeg), foot | 0 | | 4 | Int 307\|313\|314 > 0 | spacer, then `Dmg/CritDmg` | `Rating: %d/%d` ← (307 DamageRating, 314 CritDamageRating); 313 gates only | 0 | @@ -231,8 +231,13 @@ auto-fail. Guid-not-found → `Flags=0, Success=0` only. @0x004B3FF0`'s clean version of the SAME logic: one spacer before the first ratings row, one trailing spacer if any rating row was emitted. - **R5 (colorIdx RGBA):** authored in LayoutDesc 0x2100006B text attrs - (0x1B/0x1D). The row mechanism already supports authored color styles - (enchant bit styles pinned); reuse it, never hardcode RGBA. + (0x1B/0x1D). CORRECTED at the AS4 review: the row model CARRIES the + style (`CreatureAppraisalValueStyle`), but `ResolveColor` is a + deliberate no-op — every row renders the authored default color until + AP-110's "creature FontInfo-list selection" residual lands. Compute the + semantic state, never hardcode RGBA; the Society green/red is therefore + MODEL-ONLY today and INVISIBLE at the connected gate (the AS6 script + must not gate on it). - **R6 (literals):** the header/extras labels are code literals in retail — match them as literals (consistent with the item report), with DAT lookups only where retail does them (gender/heritage EnumMappers, title From adfce16bf14322eefe1ccd65885f738c9751f2db Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 10:02:00 +0200 Subject: [PATCH 71/89] docs(AS): AS4 REVIEW-CLOSED; delete the stale duplicate AS4 ledger row Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-25-assess-window-parity-campaign.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/plans/2026-08-25-assess-window-parity-campaign.md b/docs/plans/2026-08-25-assess-window-parity-campaign.md index 6dd8763d..627dc790 100644 --- a/docs/plans/2026-08-25-assess-window-parity-campaign.md +++ b/docs/plans/2026-08-25-assess-window-parity-campaign.md @@ -139,7 +139,6 @@ round → narrow re-review → REVIEW-CLOSED. | AS1 | **DONE 2026-08-25** | (docs commit) | 3-agent research; ground-truth doc committed | | AS2 | **REVIEW-CLOSED 2026-08-25** | `f8a22589` / `cc5290af` | port exact per dual-lens review; 6 findings (docs/test/refactor) fixed; +AD-114 (animated paperdoll), +AD-115 (title clear-vs-stale), PK bits promoted to `PublicWeenieFlags`; carried follow-up chip: 3 more Core PK-bit copies | | AS3 | **REVIEW-CLOSED 2026-08-25** | `1616cd3d` (no fix round) | APPROVE first pass — trio/legend/monster-path exact by offset-level decomp verification; R3 flattening theory disproven at source; ratings adjudication: pre-AS3 code already retail-exact. 5 NITs: 12 (legend-order comment), 11 (stronger refresh test) fold into AS4; 14/15 done in the close commit; 13 (geometry-keyed test helper) noted | -| AS4 | fix round (docs-only) applied, re-review pending | `4ade9b04` + docs fix commit | port exact per dual-lens review (presence-gate adjudicated FOR the implementer at `InqInt @0x005B3830`; Time-in-Dereth = pre-existing `RetailDurationText @0x00565E10` port, correct reuse); fix round was oracle-doc corrections + records only, NO code change. True full-solution hermetic count 15,528 (the commit's 15,410 was a mis-report). **AS6 carry-note: the Society green/red colorIdx is MODEL-ONLY (ResolveColor no-op pending AP-110 FontInfo residual) — the gate script must NOT gate on row colors.** Pre-existing parallel-load flake surfaced (shadow-caster zero-alloc pin) — filed in ISSUES, unrelated to AS4 | -| AS4 | pending AS3 review-close | — | | -| AS5 | pending AS4 review-close | — | | +| AS4 | **REVIEW-CLOSED 2026-08-25** | `4ade9b04` / `bf8f5b70` (docs-only fix) | port exact per dual-lens review (presence-gate adjudicated FOR the implementer at `InqInt @0x005B3830`; Time-in-Dereth = pre-existing `RetailDurationText @0x00565E10` port, correct reuse); fix round was oracle-doc corrections + records only, NO code change. True full-solution hermetic count 15,528 (the commit's 15,410 was a mis-report). **AS6 carry-note: the Society green/red colorIdx is MODEL-ONLY (ResolveColor no-op pending AP-110 FontInfo residual) — the gate script must NOT gate on row colors.** Pre-existing parallel-load flake surfaced (shadow-caster zero-alloc pin) — #442, unrelated to AS4 | +| AS5 | dispatched 2026-08-25 | — | | | AS6 | pending AS5 review-close | — | | From 8f8c0c3a07e792fc198eb655804d2bd148368acd Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 10:20:07 +0200 Subject: [PATCH 72/89] =?UTF-8?q?feat(ui):=20Campaign=20AS=20AS5=20?= =?UTF-8?q?=E2=80=94=20allegiance=20rank-title=20table,=20exam=20title=20b?= =?UTF-8?q?ar=20+=20character=20panel=20name=20line=20(G9,=20narrows=20AP-?= =?UTF-8?q?109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the 17-function heritage×gender AllegianceSystem::GetTitle @0x005B8DD0 dispatch and AllegianceData::GetFullName @0x005B6950 as AllegianceRankTitleTable (src/AcDream.App/UI/Layout/), then wires both retail call sites: the examination window's title bar (AppraisalUiController.BuildCharacterTitleBarName, from ApplyCreature's character branch) and the character panel's name line (CharacterSheetProvider.BuildSheet). Census confirmed directly against the decomp (matches AP-109's 2026-08-25 correction exactly): 11 heritages -> 17 functions. Gearknight and Tumerok author only a MALE Get*Title function, reused for both gender dispatch branches; Lugian authors only a FEMALE one, reused for both. Heritage id 0xA (Penumbraen) aliases to the Shadowbound (5) functions on both branches. Olthoi/OlthoiAcid (12/13) are excluded by GetTitle's own unsigned range check (heritage-1) <= 0xa. Every one of the 17 functions shares an identical unsigned rank bounds test (rank-1) > 9 -> no title (valid range 1..10; there is no "clamp to rank-10 title" behavior for an out-of-range rank). All 170 title strings transcribed verbatim from the decomp, including several PE-byte-recovered data-literal indirections in the Sho/Gearknight/ Tumerok tables ("Kou", "Ou", "Dux", "Ona", "Rea", "Tah") that match published AC lore exactly. GetFullName: title = GetTitle(rank, heritage, gender); when GetTitle resolves nothing, the output is the plain name; when it resolves, the output is "title" + a single ASCII space (PE-byte-recovered at data_794098) + name. Two call sites independently re-verified against the decomp for the rank/heritage/gender property ids: CharExamineUI::SetAppraiseInfo's local AllegianceData struct (proven by its ctor/dtor pair) never shows an explicit field write for _rank/_hg/_gender in the decompile — a Binary Ninja struct-flattening artifact, not a missing read — while gmStatManagementUI::UpdateCharacterInfo shows the same three CBaseQualities::InqInt(0x71/0xbc/0x1e) calls as plain, unambiguous locals, confirming Gender=0x71/HeritageGroup=0xBC/AllegianceRank=0x1E as the three inputs at both sites (ruling R8: read live off the appraisal/qualities bundle, never RuntimeAllegianceState). Register: AP-109 is NARROWED, not retired. Its stated risk (a ranked character's Name line showing plain-name-only) is closed, but the same CT4 narrowing also flagged FormatXp's non-byte-identical GetNumberFormatA approximation as "this row's own residual sliver if one is ever found" — that caveat is untouched by this slice and is now the row's only surviving open item. Tests: AllegianceRankTitleTableTests (per-function golden values, gender-reuse rules, Penumbraen alias, Olthoi exclusion, rank/heritage/ gender bounds, GetFullName composition); AppraisalUiControllerTests (title-bar prefix, plain-name fallback, monster-path regression pin); CharacterSheetProviderTests (name-line prefix + plain-name fallback). Co-Authored-By: Claude Fable 5 --- .../retail-divergence-register.md | 2 +- .../UI/Layout/AllegianceRankTitleTable.cs | 454 ++++++++++++++++++ .../UI/Layout/AppraisalUiController.cs | 31 ++ .../UI/Layout/CharacterIdentityText.cs | 36 +- .../UI/Layout/CharacterSheetProvider.cs | 15 +- .../Layout/AllegianceRankTitleTableTests.cs | 248 ++++++++++ .../UI/Layout/AppraisalUiControllerTests.cs | 109 +++++ .../UI/Layout/CharacterSheetProviderTests.cs | 30 ++ 8 files changed, 901 insertions(+), 24 deletions(-) create mode 100644 src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/AllegianceRankTitleTableTests.cs diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 3ed8bdff..1c82befa 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -360,7 +360,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-105 | **PARTIAL 2026-07-13** — inherited scrollbar media/roles now come from DAT (decrement/top `0x06004C69`, increment/bottom `0x06004C6C`), and both chat backends share typed client-command routing plus one retained `ChatVM` for reply state. Retained chat still lacks complete tab/filter/unread, social availability, incoming squelch enforcement, and focus-opacity behavior. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`; `ChatWindowController.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; chat mount in `GameWindow.cs` | Shared log/send path, wrapping, scrollbar roles, command ownership, and outer maximize geometry work; later chat work consolidates the remaining presentation/filter state | Tabs are no-ops, squelched lines can still render, contextual social actions are absent, and focus visuals diverge | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; `UIElement_Scrollbar::OnSetAttribute @ 0x004714D0`; `ChatInterface` methods | | ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` | | AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` | -| AP-109 | **NARROWED FURTHER 2026-08-25 at the Campaign CT4 fix round — the luminance pair's TEXT is now bound and the PK classification now reads the live PWD bits, closing both out of this row.** `CharacterSheetProvider.PkStatusText` classifies off `ClientObject.PublicWeenieBitfield` bits `0x20` (IsPK) / `0x02000000` (IsPKLite) — the exact `ACCWeenieObject::IsPK @0x0058c8b0` / `IsPKLite @0x0058c8a0` PWD-bitfield reads, ported already at `PlayerKillerStatusBitfield.Apply` (#297) — instead of the CT4-landed bitwise test against raw PropertyInt 134 (a non-retail mapping: PropertyInt 134 carries ACE's own `PlayerKillerStatus` enum values, not the PWD bit layout). `CharacterStatController`'s luminance pair (`0x100005C5`/`0x100005C6`) now binds real text: caption `"Luminance:"` (UTF-16, PE-byte-recovered from the `gmStatManagementUI` vftable-adjacent data region at `@0x007c3dd4`) and value `" / "` (narrow `"%s / %s"` format, PE-byte-recovered at `@0x007c3dcc`, args in that order per `UpdateExperience`'s call sequence — `ExperienceSystem::XPToString(AvailableLuminance, ...)` then `XPToString(MaximumLuminance, ...)`), both numbers formatted through the same shared `FormatXp` helper the Total XP / XP-to-level fields use (`.ToString("N0", CultureInfo.InvariantCulture)` — the C# equivalent of retail's `XPToString`→`GetNumberFormatA` locale-grouped-decimal call; not a byte-identical Win32 port, so an exotic edge case, e.g. negative/overflow, is this row's own residual sliver if one is ever found). The hide path is retail's own `UIElement_Text::ClearAllText` (`@0x004f0e31`/`@0x004f0e3c` — empties `LinesProvider` content, leaves layout) rather than `Visible = false`. (CT3's Titles-page narrowing, restored here verbatim after CT4's edit compressed it to a bare pointer phrase, still stands:) **CT3's narrowing (2026-08-24), verbatim:** `CharacterTitlesController` binds the Titles page (`gmCharacterTitleUI`, LayoutDesc `0x2100002E` element `0x10000539`) through the standard `UiTemplateListBox`/`UiScrollbar`/`UiButton` classes — no bespoke widgets: the earned-titles list sorted by resolved display string (`FindSortedInsertPosition @0x0049A760`), the current display-title text (`Refresh @0x0049abc0`, including its hardcoded `"Unknown"` fallback, refreshed on both the table-replace and display-change notices), row selection using the row template's own authored Highlight state (the same `InfoRegion::SetState(6)` mechanism CT1 confirmed for the stat rows), the "Set as Display Title" button's Ghosted-unless-a-differing-selection gate (`UpdateButtons @0x0049A500`, CORRECTED direction per the CT campaign plan's CT1 fix round — no selection is the Ghosted case), and the `TitleSet (0x002C)` wire send through CT2's `RuntimeCharacterTitleState`/`IRuntimeCharacterCommands.SetTitle` (no local mutation). Campaign CT slice CT4 (2026-08-24) then put the header identity block live: `CharacterStatController`'s Name/Heritage/PkStatus/Level labels use `LabelAuthoredColor` (the widget's own DAT-set `DefaultColor`/Outline, matching CT1's live-DAT pin — the former hardcoded `Body`/`Gold` runtime constants are deleted); the heritage line appends CT2/CT3's resolved display title VERBATIM (`CharacterIdentityText.StatHeaderLine`, CT4-fix-round-corrected 2026-08-25 to stop stripping a leading "The " — retail `AppendText`s the resolved title unmodified at `@0x004f0990`, and 26 real ACE `CharacterTitle` entries begin with "The"); the level shows `"%d"`-formatted `InqInt(0x19)` or the literal `"???"` when absent (both PE-recovered). One item remains open, registered rather than silently dropped: the NAME line ships the PLAIN-NAME case only — retail's allegiance-rank prefix (`AllegianceData::GetFullName @0x005b6950` → `AllegianceSystem::GetTitle @0x005b8dd0`) needs a ~170-string, **17-function** [CORRECTED 2026-08-25 from CT4's original 22-function/~200-string estimate — `GetTitle`'s own dispatch switch (`@0x005b8dd0`) was read directly: Gearknight and Tumerok author only a MALE `Get*Title` function, reused for both genders' dispatch branches, and Lugian authors only a FEMALE one, reused for both — 11 heritages produce 17 functions, not 22 (2 each for Aluvian/Gharu'ndim/Sho/Viamontian/Shadowbound/Empyrean/Undead, 1 each for Gearknight/Tumerok/Lugian); Olthoi/OlthoiAcid (heritage ids 12/13) have no title function at all — `GetTitle`'s own range check `(heritage-1) <= 0xa` (unsigned) excludes them, and heritage id `0xa` (Penumbraen) aliases to the Shadowbound functions] heritage×gender title table (verbatim in the decomp, e.g. `GetAluvianMaleTitle @0x005b7bc0`'s "Yeoman"/"Baronet"/.../"High King") that CT4 judged out of "reasonable size" for this slice. The RANK value is PropertyInt `0x1E` (`AllegianceRank`) read LIVE off the qualities bundle (`CBaseQualities::InqInt(qualities, 0x1e)` — ACE actively pushes this property on every allegiance-rank change) [CORRECTED 2026-08-25 — CT4's original text claimed `RuntimeAllegianceState` "already carries the local player's own rank," conflating this row's context with `SocialAllegiancePageController`'s OWN, DIFFERENT, already-documented substitution (that controller has no qualities-bundle access, so it renders `RuntimeAllegianceSnapshot.Rank` — same `0x0020 AllegianceUpdate` wire message, numerically equivalent in every observed case — as its own accepted stand-in). `CharacterSheetProvider.BuildSheet` already reads every other header property straight off `props.GetInt(...)` from the qualities-equivalent `PropertyBundle`, so the correct future port reads `props.GetInt(0x1Eu)` directly, not `RuntimeAllegianceState` — only the STRING table is missing, not the data]. | `src/AcDream.App/UI/Layout/CharacterStatController.cs` (`LabelAuthoredColor`, `RefreshLuminanceVisibility`, `FormatXp`); `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`PkStatusText`); `src/AcDream.Core/Items/ClientObject.cs` (`PlayerKillerStatusBitfield`); `src/AcDream.App/UI/Layout/CharacterSheet.cs`; `src/AcDream.App/UI/Layout/CharacterIdentityText.cs`; `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs`; `src/AcDream.App/UI/Layout/CharacterTitlesController.cs` (Titles page, CT3, unchanged) | Attributes/skills core output and the Titles-page binding seam are user-accepted; evidence for the header-identity block is synthetic-layout binding tests plus a small number of InstalledDat string/DID pins (the three PK strings, the gender/heritage EnumMapper chain at AP-235) — not a connected/live gate | A ranked-allegiance character's Name line shows plain name only (no title prefix) until the 17-function table is ported | `gmStatManagementUI::UpdateCharacterInfo @ 0x004F0770`; `UpdatePKStatus @ 0x004F00A0`; `UpdateExperience @ 0x004F0A70`; `UIElement_Text::ClearAllText @ 0x004F0E31`/`0x004F0E3C`; `ACCWeenieObject::IsPK @ 0x0058C8B0`; `IsPKLite @ 0x0058C8A0`; `AllegianceData::GetFullName @ 0x005B6950`; `AllegianceSystem::GetTitle @ 0x005B8DD0` | +| AP-109 | **NARROWED FURTHER 2026-08-25 (Campaign AS slice AS5) — the 17-function heritage×gender `AllegianceSystem::GetTitle @0x005B8DD0` table and `AllegianceData::GetFullName @0x005B6950` are now ported VERBATIM** (`AllegianceRankTitleTable`, `src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs` — every one of the 17 `Get*Title` functions transcribed string-for-string from the decomp, including the PE-byte-recovered data-literal indirections in the Sho/Gearknight/Tumerok tables) **and wired to BOTH windows this row named as open**: the examination window's title bar (`AppraisalUiController.BuildCharacterTitleBarName`, called from the `character` branch of `ApplyCreature` — rank/heritage/gender read LIVE off the APPRAISAL bundle, `props.GetInt(0x1E)`/`0xBC`/`0x71`, ruling R8, never `RuntimeAllegianceState`) and the character panel's NAME line (`CharacterSheetProvider.BuildSheet`, the exact `props.GetInt(0x1Eu)` read this row's own CT4 text already prescribed as the correct future port). Both call sites were independently re-verified against the decomp at this slice: `CharExamineUI::SetAppraiseInfo`'s local `AllegianceData` (BN name `var_a8`, proven by its `CAllegianceData::CAllegianceData`/`~AllegianceData` ctor/dtor pair) never shows an explicit field WRITE for `_rank`/`_hg`/`_gender` — a Binary Ninja struct-flattening artifact, not a missing read — while `gmStatManagementUI::UpdateCharacterInfo` shows the same three `CBaseQualities::InqInt(0x71/0xbc/0x1e)` calls as plain, unambiguous locals, independently confirming the property ids this row's CT4 text already named. This closes the row's stated risk (a ranked character's Name line showing plain-name-only). **NOT closed by this slice, and the reason this row survives NARROWED rather than RETIRED:** the CT4 narrowing's own `FormatXp` caveat, immediately below — the Luminance pair's number formatting remains a `.ToString("N0", CultureInfo.InvariantCulture)` approximation of retail's `XPToString`→`GetNumberFormatA` Win32 call, unverified on an exotic negative/overflow input. That caveat is now this row's ONLY open item.** **NARROWED FURTHER 2026-08-25 at the Campaign CT4 fix round — the luminance pair's TEXT is now bound and the PK classification now reads the live PWD bits, closing both out of this row.** `CharacterSheetProvider.PkStatusText` classifies off `ClientObject.PublicWeenieBitfield` bits `0x20` (IsPK) / `0x02000000` (IsPKLite) — the exact `ACCWeenieObject::IsPK @0x0058c8b0` / `IsPKLite @0x0058c8a0` PWD-bitfield reads, ported already at `PlayerKillerStatusBitfield.Apply` (#297) — instead of the CT4-landed bitwise test against raw PropertyInt 134 (a non-retail mapping: PropertyInt 134 carries ACE's own `PlayerKillerStatus` enum values, not the PWD bit layout). `CharacterStatController`'s luminance pair (`0x100005C5`/`0x100005C6`) now binds real text: caption `"Luminance:"` (UTF-16, PE-byte-recovered from the `gmStatManagementUI` vftable-adjacent data region at `@0x007c3dd4`) and value `" / "` (narrow `"%s / %s"` format, PE-byte-recovered at `@0x007c3dcc`, args in that order per `UpdateExperience`'s call sequence — `ExperienceSystem::XPToString(AvailableLuminance, ...)` then `XPToString(MaximumLuminance, ...)`), both numbers formatted through the same shared `FormatXp` helper the Total XP / XP-to-level fields use (`.ToString("N0", CultureInfo.InvariantCulture)` — the C# equivalent of retail's `XPToString`→`GetNumberFormatA` locale-grouped-decimal call; not a byte-identical Win32 port, so an exotic edge case, e.g. negative/overflow, is this row's own residual sliver if one is ever found). The hide path is retail's own `UIElement_Text::ClearAllText` (`@0x004f0e31`/`@0x004f0e3c` — empties `LinesProvider` content, leaves layout) rather than `Visible = false`. (CT3's Titles-page narrowing, restored here verbatim after CT4's edit compressed it to a bare pointer phrase, still stands:) **CT3's narrowing (2026-08-24), verbatim:** `CharacterTitlesController` binds the Titles page (`gmCharacterTitleUI`, LayoutDesc `0x2100002E` element `0x10000539`) through the standard `UiTemplateListBox`/`UiScrollbar`/`UiButton` classes — no bespoke widgets: the earned-titles list sorted by resolved display string (`FindSortedInsertPosition @0x0049A760`), the current display-title text (`Refresh @0x0049abc0`, including its hardcoded `"Unknown"` fallback, refreshed on both the table-replace and display-change notices), row selection using the row template's own authored Highlight state (the same `InfoRegion::SetState(6)` mechanism CT1 confirmed for the stat rows), the "Set as Display Title" button's Ghosted-unless-a-differing-selection gate (`UpdateButtons @0x0049A500`, CORRECTED direction per the CT campaign plan's CT1 fix round — no selection is the Ghosted case), and the `TitleSet (0x002C)` wire send through CT2's `RuntimeCharacterTitleState`/`IRuntimeCharacterCommands.SetTitle` (no local mutation). Campaign CT slice CT4 (2026-08-24) then put the header identity block live: `CharacterStatController`'s Name/Heritage/PkStatus/Level labels use `LabelAuthoredColor` (the widget's own DAT-set `DefaultColor`/Outline, matching CT1's live-DAT pin — the former hardcoded `Body`/`Gold` runtime constants are deleted); the heritage line appends CT2/CT3's resolved display title VERBATIM (`CharacterIdentityText.StatHeaderLine`, CT4-fix-round-corrected 2026-08-25 to stop stripping a leading "The " — retail `AppendText`s the resolved title unmodified at `@0x004f0990`, and 26 real ACE `CharacterTitle` entries begin with "The"); the level shows `"%d"`-formatted `InqInt(0x19)` or the literal `"???"` when absent (both PE-recovered). One item remains open, registered rather than silently dropped: the NAME line ships the PLAIN-NAME case only — retail's allegiance-rank prefix (`AllegianceData::GetFullName @0x005b6950` → `AllegianceSystem::GetTitle @0x005b8dd0`) needs a ~170-string, **17-function** [CORRECTED 2026-08-25 from CT4's original 22-function/~200-string estimate — `GetTitle`'s own dispatch switch (`@0x005b8dd0`) was read directly: Gearknight and Tumerok author only a MALE `Get*Title` function, reused for both genders' dispatch branches, and Lugian authors only a FEMALE one, reused for both — 11 heritages produce 17 functions, not 22 (2 each for Aluvian/Gharu'ndim/Sho/Viamontian/Shadowbound/Empyrean/Undead, 1 each for Gearknight/Tumerok/Lugian); Olthoi/OlthoiAcid (heritage ids 12/13) have no title function at all — `GetTitle`'s own range check `(heritage-1) <= 0xa` (unsigned) excludes them, and heritage id `0xa` (Penumbraen) aliases to the Shadowbound functions] heritage×gender title table (verbatim in the decomp, e.g. `GetAluvianMaleTitle @0x005b7bc0`'s "Yeoman"/"Baronet"/.../"High King") that CT4 judged out of "reasonable size" for this slice. The RANK value is PropertyInt `0x1E` (`AllegianceRank`) read LIVE off the qualities bundle (`CBaseQualities::InqInt(qualities, 0x1e)` — ACE actively pushes this property on every allegiance-rank change) [CORRECTED 2026-08-25 — CT4's original text claimed `RuntimeAllegianceState` "already carries the local player's own rank," conflating this row's context with `SocialAllegiancePageController`'s OWN, DIFFERENT, already-documented substitution (that controller has no qualities-bundle access, so it renders `RuntimeAllegianceSnapshot.Rank` — same `0x0020 AllegianceUpdate` wire message, numerically equivalent in every observed case — as its own accepted stand-in). `CharacterSheetProvider.BuildSheet` already reads every other header property straight off `props.GetInt(...)` from the qualities-equivalent `PropertyBundle`, so the correct future port reads `props.GetInt(0x1Eu)` directly, not `RuntimeAllegianceState` — only the STRING table is missing, not the data]. | `src/AcDream.App/UI/Layout/CharacterStatController.cs` (`LabelAuthoredColor`, `RefreshLuminanceVisibility`, `FormatXp`); `src/AcDream.App/UI/Layout/CharacterSheetProvider.cs` (`PkStatusText`); `src/AcDream.Core/Items/ClientObject.cs` (`PlayerKillerStatusBitfield`); `src/AcDream.App/UI/Layout/CharacterSheet.cs`; `src/AcDream.App/UI/Layout/CharacterIdentityText.cs`; `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs`; `src/AcDream.App/UI/Layout/CharacterTitlesController.cs` (Titles page, CT3, unchanged); `src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs` (AS5, new — the 17-function title table + `GetFullName` port); `src/AcDream.App/UI/Layout/AppraisalUiController.cs` (AS5, examination window title-bar overwrite) | Attributes/skills core output and the Titles-page binding seam are user-accepted; evidence for the header-identity block is synthetic-layout binding tests plus a small number of InstalledDat string/DID pins (the three PK strings, the gender/heritage EnumMapper chain at AP-235); AS5 adds hermetic golden-value conformance tests for all 17 `Get*Title` functions (`AllegianceRankTitleTableTests`) plus fixture-layout binding tests for both the examination title bar and the character panel name line — still not a connected/live gate | The title-table gap is CLOSED (was: a ranked-allegiance character's Name line showing plain name only). The row's ONLY remaining risk: an exotic negative/overflow Luminance value could format differently than retail's byte-exact `GetNumberFormatA` — `FormatXp`'s `.ToString("N0", CultureInfo.InvariantCulture)` is a documented approximation, not a byte-identical Win32 port, and this has never been observed or reproduced | `gmStatManagementUI::UpdateCharacterInfo @ 0x004F0770`; `UpdatePKStatus @ 0x004F00A0`; `UpdateExperience @ 0x004F0A70`; `UIElement_Text::ClearAllText @ 0x004F0E31`/`0x004F0E3C`; `ACCWeenieObject::IsPK @ 0x0058C8B0`; `IsPKLite @ 0x0058C8A0`; `CharExamineUI::SetAppraiseInfo @ 0x004B45F0`; `AllegianceData::GetFullName @ 0x005B6950`; `AllegianceSystem::GetTitle @ 0x005B8DD0` | | AP-110 | **NARROWED 2026-08-25 (Campaign AS, the assess/examination-window retail-parity campaign) — "exhaustive character detail regions" retired from the still-lacks list.** AS2 ported the player header identity block (composed gender+heritage, current display title, PK status from the local weenie's PWD bits, allegiance name); AS3 ported the per-bodypart armor-level trio (with the `*` unenchantable sentinel), the ratings-family spacer discipline, and the unconditional `* = Unenchantable` legend; AS4 ported the remaining extras-list rows — society/faction (rank bands, local-vs-target faction color rule), the Monarch/Patron/Followers cascade, and the seven configurable extras (Fellowship, Arrived in Dereth, Time in Dereth, Chess Rank, Fishing Skill, Deaths, Titles Earned) — closing the character-path extras list end to end. See `docs/research/2026-08-25-campaign-as-ground-truth.md` for the full row-by-row decomp citations; the row's OTHER residuals (item-object preview, effective shield projection, cooldown-remaining, augmentation-cost `StringInfo`, creature FontInfo-list selection) are untouched by this campaign and remain open below. **NARROWED 2026-08-09 (Slice 5.4, vendor browse panel) — "vendor" retired from the absent-panels list; see AP-161 for the precise successor (Buy/Sell transaction UI, Slice 6).** Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, inscription transaction, retail creature stat/rating/animated-preview presentation, default selection-follow, authored local spell subview with appropriate-formula component state, and the full EoR item-report dispatch: appraisal-only unknowns; exact equipment-set/rating/tinkering/weapon/armor/caster/requirement/XP/healer/rare prose and intentional blank section rows; ordinary/enchantment DAT spell descriptions; live material-decorated appropriate titles plus DAT material and creature names; expiry, decorated material/gem descriptions; and portal/PK restrictions with authored item colors. It still lacks item-object preview, player-dependent effective shield projection, live cooldown-remaining projection, localized augmentation-cost `StringInfo`, and exact creature appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, the Slice 5.4 vendor "Items" browse panel (category-filtered stock list, retail's quantity-correct pricing — `ItemHolder::GetObjectSplitSize`'s split-exempt mask, not a flat per-unit price), and the core examination request/presentation/inscription/creature-preview/item-report loop cover the active loops; the residual examination mechanisms require live player/enchantment/localization state or object-preview ownership rather than fabricated content | Item assessments omit only the listed live/localized/preview projections; enchanted/incomplete creature appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CharExamineUI::SetAppraiseInfo @ 0x004B45F0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `gmExaminationUI::ExamineSpell @ 0x004B6900`; `SpellExamineUI::ExamineSpell @ 0x004B6210`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ACCWeenieObject::GetObjectName @ 0x0058E6E0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::AddItemInfo @ 0x004AC050`; `ItemExamineUI::Appraisal_ShowCapacity @ 0x004B2680`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::Appraisal_ShowDescription @ 0x004B6990`; `MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog | | AP-161 | **REVIEW CORRECTIONS 2026-08-09 (Opus review of `92ea3977`, findings F1-F13):** thirteen further fixes, mostly bug-fixes-to-already-claimed-behavior rather than new divergences, so no new AP row is filed for most of them; the exceptions are called out below. F1 ports Buy All's four retail pre-send guards (pyreal/alt-currency affordability, container-slot/item-slot capacity) — see AP-162's narrowing. F2 corrects `AddToBuyList` from upsert to retail's actual ACCUMULATE-with-5000-cap semantics and ports `RemoveFromShop`'s shop-row hide/restore as staging consumes limited vendor supply. F3 corrects `VendorSellAcceptability`'s too-valuable branch to the byte-verified bitwise-complement form (`(~(itemTypeMask >> 16)) & 4`), exempting `PromissoryNote` items. F4 wires `BF_RETAINED` end to end, RETIRING AP-164 below. F5 ports `UpdateDragOver`'s auto-switch-to-Selling-on-hover. F6 corrects sell staging to always record the FULL stack (never the live split slider) and ports `SellSingleItem`'s partial-stack refusal plus its literal amount-1 send. F7 corrects the X-close confirmation string's missing trailing question mark. F8 disposes a live confirmation dialog on session Close/Reset. F9 repaints the Buying/Selling strips' own selection highlight on every selection change, not just a staging change. F10 unstages a sell entry that leaves `ClientObjectTable` and a buy entry whose shop row is retired, the latter with retail's exact notice. F11 reorders `RequestUse`'s eligibility check ahead of `BeginApproach` so an ineligible far target no longer speculatively approaches. F13 makes Sell Item act on the global selection unconditionally, matching retail — a prior version of this port required a staged entry first. New approximations this pass introduced are filed as AP-167 (`SellSingleItem`'s non-empty-container refusal branch not ported) and AP-168 (Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container`). **NARROWED 2026-08-09 (Slice 6b/6c, staging+sell arc) — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES.** `VendorUiController` now fully wires both tabs: Buying (`Add to List`/`Buy Item`/`Buy All`/`Clear Item`/`Clear List`, backed by `VendorStagingList`) and Selling (drag-to-sell via `IItemListDragHandler`, `VendorSellAcceptability`'s port of `VendorProfile::InqAcceptability`, `Sell Item`/`Sell All`/`Clear Item`/`Clear List`), plus the X-close staging confirmation dialog (`RetailDialogFactory`, the exact retail string recovered from the decompiled binary's data segment at `0x007b5bd8`). Sell (`0x0060`) is wired end to end (`VendorRequests.BuildSell`/`WorldSession.SendSell`/`ItemInteractionController.TrySell`). Three narrow residuals from this pass are filed separately rather than folded in here: `InqAcceptability`'s non-sellable bitfield check is unmodeled (AP-164), the Buying tab's stackable-removal-amount test substitutes `VendorShopItem.DescStackSize` for retail's `_maxStackSize` (AP-165), and the Buying/Selling tabs' own per-row/purse count text plus the cross-panel "pending sell" inventory highlight are not wired (AP-166). The two PRE-EXISTING residuals below (dropdown arrow-cap glyph, alt-currency `m_last_sale` simplification) are UNCHANGED by this pass — see the ORIGINAL text below for their citations. **REVIEW CORRECTIONS 2026-08-09 (Opus review of `97cf8738`, findings F1-F9):** none of these are NEW divergences from retail — they are bug fixes that make this row's own claims actually true, so no new AP row is filed for them. F2 fixed the priced/named quantity freezing at a selection-time seed while the Buy button separately read the LIVE slider — both now share one `ResolveBuyQuantity` computation, so the displayed price always equals what a purchase actually charges (retail: `gmVendorUI::RecvNotice_StackSliderChanged` re-runs the SAME display update on every slider change, `pc:203262-203278`). F6 corrected an unauthored "preserve the prior selection if it survives the filter" rule to retail's actual UNCONDITIONAL reselect-to-first-item on every rebuild this controller reaches (`VendorItemsUI::UpdateItemsList`'s notify=1 path, `pc:201180-201184`, confirmed reached by a fresh open AND a same-vendor refresh via `VendorItemsUI::OpenVendor`'s unconditional `SetSelectedItem(...,1)`, `pc:201022`). F7 ported `BuySingleItem`'s stack-size-1 quantity clamp (`pc:201674-201681`) so a stale slider value left over from a previously-selected, DIFFERENT stackable item cannot leak into a non-stack purchase. F8 is recorded inline below, where it corrects this row's own stale claim about the Add-to-List button. **NARROWED 2026-08-09 (Slice 6.1-6.3, buy arc) — TWO of the four consciously-deferred residuals below CLOSE.** Private per-panel selection is GONE: `SelectionState` gains a `Vendor` change source (`SelectionChangeSource.Vendor`) and is now the AUTHORITY — row clicks, the F4 auto-select-first-item fallback, and right-click examine all call `SelectionState.Select`/`Clear`; `VendorUiController` is a CONSUMER (`OnSelectionTransition`) exactly like every sibling panel, matching retail's global `ACCWeenieObject::selectedID`. The examine gap (F7c) is GONE too: `VendorShopItemMaterializer` (`src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs`, Slice 6.1) registers every `ApproachVendor` shop item into `ClientObjectTable` (guid, `ContainerId = vendorGuid`, merge-upserted via the ordinary `Ingest` path, retired on session Close/Reset/vendor-replace via the SAME `VendorState.Changed` subscription) so `AppraisalUiController.Apply`'s lookup now succeeds; `VendorUiController.ExamineItem` wires `UiItemList.ExamineItemRequested` to `ItemInteractionController.ExamineSelectedOrEnterMode`, mirroring `ExternalContainerController`. **Double-click-to-buy was investigated (research doc `docs/research/2026-08-08-slice6-vendor-transactions-research.md` §B.2) and confirmed ABSENT from retail** — no `gmVendorUI::CheckForDoubleClick`/`VendorItemsUI::CheckForDoubleClick` symbol exists anywhere in the 18,366-function named table, unlike sibling panels (`gmContractsUI::CheckForDoubleClick`, `gmPageListUI::CheckForDoubleClick`) that DO have one; acdream intentionally does NOT add a double-click shortcut — a user request for it as a deliberate acdream-only UX addition would need its own AP row, per CLAUDE.md's no-invented-mechanisms discipline. The remaining two residuals (dropdown arrow-cap glyph, alt-currency `m_last_sale` simplification) are UNCHANGED — see below. New approximations this pass introduced are filed separately as AP-162 (no client-side Buy pre-check) and AP-163 (shop-item guid-collision policy). **Original REWRITTEN text follows, retained for the two still-open residuals:** `VendorUiController` mounts LayoutDesc `0x21000012`/root `0x100000B7` and fully wires only the authored "Items" tab (`0x100000B9` — `VendorItemsUI`: category-filtered browse list with retail's quantity-correct pricing, `ItemHolder::GetObjectSplitSize`'s `0xDC41CB0` split-exempt mask ported locally rather than a flat per-unit price). The other two authored tabs render and switch pages (so the layout looks complete) but are otherwise INERT: "Buying" (`0x100000BA`, `VendorBuyUI` — staged-purchase review/confirm, buttons `0x100000C9`/`CA`/`CB`/`CC`) and "Selling" (`0x100000BB`, `VendorSellUI` — staged-sale review/confirm, buttons `0x100000D2`/`D3`/`D4`/`D5`) have no data binding at all. The "Items" page's own `Buy` button (`0x100000C2`) correctly enables/disables with selection (`UiButton.Enabled`, retail `SetState(1)`/`SetState(0xd)`) and Slice 6.3 wires it to a real immediate single-item purchase (`gmVendorUI::BuySingleItem`, `pc:201661` — `VendorRequests.BuildBuy`/`WorldSession.SendBuy`, opcode `0x005F`). **Review correction 2026-08-09 (F8):** `Add to List` (`0x100000C3`, staging) does NOT enable/disable with selection — it is PERMANENTLY disabled (`VendorUiController.SetActionButtonsEnabled`), because it has no wired `OnClick` at all; an enabled-but-dead button is a worse affordance than a disabled one, so it stays disabled until the "Buying" tab's staging list is actually implemented. The Buy opcode exists on the wire now; Sell (`0x0060`) does not. `VendorProfile::InqAcceptability` (sell-eligibility filtering) is unread — moot without a sell UI. Two divergences remain of the four the F1-F8 fix pass originally recorded — the other two (private per-panel selection, unwired shop-item examine) CLOSED at Slice 6.1/6.2, see the NARROWED note above: (1) the closed-dropdown button face reuses the row template's own two sprites (`0x060012B3` normal/`0x060012B4` open) through `UiMenu`'s existing single-texture 3-slice `DrawButtonFace` instead of retail's authored two-piece label+arrow-cap assembly (label `0x1000034D` + a separate 17x19 arrow cap `0x1000034E` with its own `0x060012B1`/`0x060012B2` states) — a cosmetic gap only; the popup panel and its rows render with the exact authored geometry and sprites; (2) the alt-currency "you have" holding reads `VendorShopProfile.AlternateCurrencyAmount` directly instead of tracking retail's `gmVendorUI.m_last_sale` purchase debit — moot until a sell path exists to actually debit it, since `m_last_sale` only changes on a completed SALE (retail's own `m_last_sale == 0` case, `pc:204091`/`OpenVendor`'s `this->m_last_sale = 0` reset at `pc:203790`/`203800`); Slice 6.3's buy path does not touch `m_last_sale` either (retail's own buy flow never writes it), so this residual is unaffected by the buy arc landing. The "Buying"/"Selling" staging tabs (`VendorBuyUI`/`VendorSellUI`) and the full Sell wire remain unwired — unchanged Slice 6b/6c territory per contract decision 6, not a residual of THIS row. | `src/AcDream.App/UI/Layout/VendorUiController.cs`; `src/AcDream.Core/Items/VendorState.cs`; `src/AcDream.Core.Net/GameEventWiring.cs`; `src/AcDream.App/UI/RetailUiRuntime.cs` | Slice 6 (`docs/plans/2026-07-23-world-interaction-completion.md`) owns the authoritative buy/sell transaction command, quantity/stack-split selection, drag-to-sell consumption, and `InqAcceptability`-gated sell UI — Slice 5.4's charter was browse only. Buy (6.3), the global `ACCWeenieObject::selectedID` coupling (6.2), and shop-item `ClientObjectTable` registration (6.1) are now DONE, landing exactly the seam this row's original filing fenced off; drag-to-sell consumption and `InqAcceptability`-gated sell UI remain Slice 6b/6c territory. | A player can browse, select, examine, and BUY (Slice 6.3) — the only remaining unbuilt transaction is Sell. Clicking "Buying"/"Selling" still shows an empty page with no error or explanation, matching "present but does nothing" rather than a disabled/hidden affordance. The dropdown's closed-state button face is missing its separate arrow-cap glyph — a minor visual gap, not a functional one; the open popup itself is pixel-faithful to the authored template. | `gmVendorUI::OpenVendor` pc:203650 (`m_itemsUI`/`m_buyUI`/`m_sellUI` construction, `PostInit` pc:199906, `m_last_sale` reset pc:203790/203800); `VendorBuyUI::VendorBuyUI` pc:199717; `VendorSellUI::VendorSellUI` pc:199753; `VendorProfile::InqAcceptability` pc:484768-484797; `UIElement_Menu::MakePopup` pc:120705-120764, `::Initialize` pc:120789-120828; `VendorItemsUI::UpdateItemsUI` pc:202539-202820; `VendorItemsUI::UpdateItemsList` pc:201029-201190; `ItemHolder::GetObjectSplitSize` pc:401465-401477; `gmToolbarUI::HandleSelectionChanged` pc:198740-198790 (mask `0xDC41CB0` at pc:198784); `ACCWeenieObject::GetObjectName` pc:409056-409132; `docs/research/2026-08-08-slice5-vendor-browse-research.md` §B.4, §D | | AP-162 | **NARROWED 2026-08-09 (Opus review of `92ea3977`, finding F1) — the "Buy All" half of this row CLOSES.** `VendorUiController.BuyAllButtonPressed` now ports all four of retail's client-side pre-send guards (pyreal affordability `pc:204017`, alt-currency affordability `pc:204032`, container-slot capacity `pc:204053`, item-slot capacity `pc:204067`) — see `ComputeBuyTransactionValue`/`ComputeBuySlotsNeeded`/`CountPlayerContents`, each guard returning with staging fully intact and retail's own exact notice string (`"You don't have enough money"` at `0x007b57b4`, `"You must empty some slots in your backpack first"` at `0x007b5750`, both byte-recovered). The container-vs-item slot CLASSIFICATION this port uses (`ItemType.Container` instead of retail's bitfield/capacity test) is its own new, narrower approximation — filed separately as AP-168 rather than folded in here. Only `TryBuy`'s single-item Buy path (Items tab's own Buy button, and the Buying tab's "Buy Item") remains WITHOUT a client-side pre-check — the risk/oracle columns below now describe that one remaining case, not both. **EXTENDED 2026-08-09 (Slice 6b) — the same omission now also covers "Buy All".** `ItemInteractionController.TryBuyAll` (the batched-send path `VendorUiController.BuyAllButtonPressed` calls) sends unconditionally too, without porting retail's `pc:204017/204032/204053/204067` affordability/pack-capacity pre-checks for the MULTI-item case either — the same latency-not-correctness tradeoff this row already documents for the single-item path, extended rather than duplicated into a second row; retiring this row should port both the single- and batched-send pre-checks together. **Filed 2026-08-09, Slice 6.3 (buy wire + button).** Retail's `BuySingleItem` (`pc:201661`) performs TWO client-side pre-checks before ever sending `CM_Vendor::Event_Buy`: (a) an affordability check against `this->m_totalValue` (pyreal) or `shopVendorProfile->trade_num - m_last_sale` (alt-currency), showing a LOCAL string via `ECM_UI::SendNotice_DisplayStringInfo` and returning without sending anything on failure (`pc:201686-201717`); (b) a pack/container-capacity pre-check (`pc:201730-201746`) mirroring the server's own check. acdream's `ItemInteractionController.TryBuy` sends unconditionally once the shared use/inventory gate is free — no client-side affordability or capacity check runs before dispatch. Every refused purchase pays a full round-trip (send → server rejects → `UseDone`/`GameEventInventoryServerSaveFailed`) instead of failing instantly and silently client-side. **Swept 2026-08-09 (F4 review fix):** `TryBuy` now also checks whether `sendBuy` actually reached a live, in-world session before marking the reservation dispatched — an orthogonal reservation-leak bug fix (no session ever produced a stray permanent busy-lock), not an affordability/capacity check; this row's scope and residual are unchanged. | `src/AcDream.App/UI/ItemInteractionController.cs` (`TryBuy`) | The research doc's own open question 1 (`docs/research/2026-08-08-slice6-vendor-transactions-research.md`) recommends deferring this: the server is authoritative either way (ACE re-validates both affordability and capacity server-side — `Vendor.BuyItems_ValidateTransaction`, `Vendor.cs:431-571`), so omitting the client pre-check is a LATENCY/UX gap, not a correctness one — a refused purchase still fails cleanly, just one round-trip later than retail. | A player attempting to buy something they cannot afford or have no room for sees the failure arrive after a network round-trip instead of instantly; against a well-behaved ACE server no purchase can succeed that retail's pre-check would have blocked, so no transaction outcome differs — only its latency. Retiring this row means porting `BuySingleItem`'s two pre-check branches (`pc:201686-201746`) into `TryBuy` before dispatch. | `gmVendorUI::BuySingleItem` pc:201661/0x004C2820 (affordability pc:201686-201717, capacity pc:201730-201746); `Vendor.BuyItems_ValidateTransaction` (`references/ACE/Source/ACE.Server/WorldObjects/Vendor.cs:431-571`); `docs/research/2026-08-08-slice6-vendor-transactions-research.md` §D point 4, Open question 1 | diff --git a/src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs b/src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs new file mode 100644 index 00000000..7b92545e --- /dev/null +++ b/src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs @@ -0,0 +1,454 @@ +namespace AcDream.App.UI.Layout; + +/// +/// Campaign AS slice AS5 (2026-08-25, retires register row AP-109): the +/// retail allegiance rank-title table and its name-composition wrapper. +/// +/// Dispatch — AllegianceSystem::GetTitle @0x005B8DD0. +/// Retail tests gender first (arg3: 1 = male, 2 = female — anything +/// else falls through to "no title"), then an UNSIGNED heritage-group range +/// check (arg2 - 1) <= 0xa (heritage ids 1..11; this is what +/// excludes Olthoi/OlthoiAcid, ids 12/13, from ever getting a title), then +/// switches on heritage to one of 17 Get*Title functions. The dispatch +/// was READ DIRECTLY from the decomp (not estimated) for AP-109's +/// 2026-08-25 correction: Gearknight (heritage 6) and Tumerok (heritage 7) +/// author only a MALE function, reused verbatim for the FEMALE dispatch +/// branch too; Lugian (heritage 8) authors only a FEMALE function, reused +/// for the MALE branch; heritage id 0xA (Penumbraen) aliases to the +/// Shadowbound (heritage 5) functions on BOTH gender branches — 11 +/// heritages, 17 functions, not 22. +/// +/// Per-function bounds — every one of the 17 functions. Each +/// does its OWN unsigned bounds check, identical across all 17: +/// (arg1 - 1) > 9 → return 0 (no title). So a valid rank is +/// exactly 1..10 inclusive; rank 0 (unsigned wraps to a huge value) and any +/// rank > 10 both resolve to "no title" — there is no separate +/// "too high" clamp to the rank-10 title, contrary to what a naive port +/// might assume. +/// +/// Composition — AllegianceData::GetFullName @0x005B6950. +/// title = GetTitle(this->_rank, this->_hg, this->_gender); +/// when GetTitle returns 0 (no title resolved), the output is the +/// plain name, unmodified. When it resolves, the output is +/// title + " " + name — a single ASCII space, PE-byte-recovered at +/// data_794098 (bytes 20 00 00 00), the SAME separator +/// 's class remarks already cite for this +/// exact literal. Two call sites verified independently against the +/// decomp, both confirming (0x1E) / +/// (0xBC) / +/// (0x71) as the three +/// inputs: +/// +/// CharExamineUI::SetAppraiseInfo @0x004B45F0 (examination +/// title bar, `@0x004b4c8c`-`@0x004b4cec`): reads +/// AppraisalProfile::InqInt(arg2, 0x1e, ...) for rank immediately +/// before the GetFullName call; heritage/gender were read earlier in +/// the SAME function (InqInt(arg2, 0x71, ...) / +/// InqInt(arg2, 0xbc, ...)) for the heritage-line composition. The +/// local AllegianceData object (BN name var_a8, constructed via +/// CAllegianceData::CAllegianceData(&var_a8) and destructed via +/// AllegianceData::~AllegianceData(&var_a8) — the ctor/dtor pair +/// is the only proof BN's flattened locals belong to one struct) never shows +/// an explicit field WRITE for _rank/_hg/_gender in the +/// decompile — a known Binary Ninja struct-flattening artifact +/// (feedback_bn_decomp_field_names.md): the InqInt OUTPUT +/// pointers target those stack fields directly, and BN labels the pointed-to +/// slot with a synthetic local name instead of recognizing it as a struct +/// member. The name half of the struct IS visible as a normal assignment: +/// var_a8 = ACCWeenieObject::GetObjectName(cur_weenobj, NAME_APPROPRIATE, +/// 0) — the assessed object's own live "appropriate name", same +/// resolver +/// already serves AppraisalUiController.BuildTitle's fallback. The +/// result overwrites the window's m_displayedNameText +/// UNCONDITIONALLY (only a null-widget guard, no rank gate at the call +/// site — the "plain name when rankless" behavior lives entirely inside +/// GetFullName/GetTitle). +/// gmStatManagementUI::UpdateCharacterInfo @0x004F0770 +/// (character panel name line, `@0x004f0807`-`@0x004f0895`): this one shows +/// the three CBaseQualities::InqInt reads (0x71, 0xbc, 0x1e) as +/// plain, unambiguous locals with no flattening ambiguity — direct +/// confirmation of the same three property ids, this time straight off the +/// CACQualities qualities bundle rather than an +/// AppraisalProfile. The name appended is the LOCAL PLAYER's own +/// singular name (ACCWeenieObject::GetObjectName(SmartBox::player_id, +/// NAME_SINGULAR, 0)) — this window always shows your own sheet, never +/// another player's. +/// +/// +/// +internal static class AllegianceRankTitleTable +{ + /// PropertyInt 0x1E = AllegianceRank — the value + /// GetTitle's arg1 reads, per both verified call sites + /// above. Ruling R8 (AS1 ground truth): read this LIVE off the + /// appraisal/qualities bundle, never RuntimeAllegianceState (that + /// state exists for a DIFFERENT UI, the local player's own allegiance + /// page, and has no bearing on another player's rank). + public const uint AllegianceRankPropertyId = 0x1Eu; + + /// + /// Port of AllegianceData::GetFullName @0x005B6950: prefixes + /// with the resolved rank title + a single space + /// when resolves one; returns + /// unmodified otherwise (retail's own + /// if (GetTitle(...) == 0) { *out = name; return 0; } branch). + /// + public static string ComposeFullName(int rank, int heritageGroup, int gender, string name) + { + string? title = GetTitle(rank, heritageGroup, gender); + return string.IsNullOrEmpty(title) ? name : $"{title} {name}"; + } + + /// + /// Port of AllegianceSystem::GetTitle @0x005B8DD0's dispatch. + /// Returns null where retail returns 0 (no title: unrecognized gender, + /// heritage outside 1..11, or the resolved Get*Title function + /// itself rejects the rank). + /// + public static string? GetTitle(int rank, int heritageGroup, int gender) + { + if (gender == 1) + { + if (!IsHeritageInRange(heritageGroup)) return null; + return heritageGroup switch + { + 1 => GetAluvianMaleTitle(rank), + 2 => GetGharundimMaleTitle(rank), + 3 => GetShoMaleTitle(rank), + 4 => GetViamontianMaleTitle(rank), + 5 or 0xA => GetShadowboundMaleTitle(rank), // 0xA = Penumbraen alias + 6 => GetGearknightMaleTitle(rank), + 7 => GetTumerokMaleTitle(rank), + 8 => GetLugianFemaleTitle(rank), // Lugian authors FEMALE only; reused here + 9 => GetEmpyreanMaleTitle(rank), + 0xB => GetUndeadMaleTitle(rank), + _ => null, + }; + } + + if (gender == 2) + { + if (!IsHeritageInRange(heritageGroup)) return null; + return heritageGroup switch + { + 1 => GetAluvianFemaleTitle(rank), + 2 => GetGharundimFemaleTitle(rank), + 3 => GetShoFemaleTitle(rank), + 4 => GetViamontianFemaleTitle(rank), + 5 or 0xA => GetShadowboundFemaleTitle(rank), // 0xA = Penumbraen alias + 6 => GetGearknightMaleTitle(rank), // Gearknight authors MALE only; reused here + 7 => GetTumerokMaleTitle(rank), // Tumerok authors MALE only; reused here + 8 => GetLugianFemaleTitle(rank), + 9 => GetEmpyreanFemaleTitle(rank), + 0xB => GetUndeadFemaleTitle(rank), + _ => null, + }; + } + + return null; + } + + /// Retail's unsigned (heritage - 1) <= 0xa range test + /// — heritage ids 1..11 (0xA = Penumbraen, 0xB = Undead); excludes 0, + /// negative, and Olthoi/OlthoiAcid (12/13). + private static bool IsHeritageInRange(int heritageGroup) + => unchecked((uint)(heritageGroup - 1)) <= 0xAu; + + // ── The 17 Get*Title functions, verbatim from the decomp. ────────────── + // Every function shares the identical bounds test `(rank - 1) > 9` -> + // return 0 (ported here as the switch's default arm returning null, + // since rank values outside 1..10 have no case in any of the 17 retail + // switches either). + + /// AllegianceSystem::GetAluvianMaleTitle @0x005B7BC0 + private static string? GetAluvianMaleTitle(int rank) => rank switch + { + 1 => "Yeoman", + 2 => "Baronet", + 3 => "Baron", + 4 => "Reeve", + 5 => "Thane", + 6 => "Ealdor", + 7 => "Duke", + 8 => "Aetheling", + 9 => "King", + 10 => "High King", + _ => null, + }; + + /// AllegianceSystem::GetAluvianFemaleTitle @0x005B7CD0 + private static string? GetAluvianFemaleTitle(int rank) => rank switch + { + 1 => "Yeoman", + 2 => "Baronet", + 3 => "Baroness", + 4 => "Reeve", + 5 => "Thane", + 6 => "Ealdor", + 7 => "Duchess", + 8 => "Aetheling", + 9 => "Queen", + 10 => "High Queen", + _ => null, + }; + + /// AllegianceSystem::GetGharundimMaleTitle @0x005B7DE0 + private static string? GetGharundimMaleTitle(int rank) => rank switch + { + 1 => "Sayyid", + 2 => "Shayk", + 3 => "Maulan", + 4 => "Mu'allim", + 5 => "Naquib", + 6 => "Qadi", + 7 => "Mushir", + 8 => "Amir", + 9 => "Malik", + 10 => "Sultan", + _ => null, + }; + + /// AllegianceSystem::GetGharundimFemaleTitle @0x005B7EF0 + private static string? GetGharundimFemaleTitle(int rank) => rank switch + { + 1 => "Sayyida", + 2 => "Shayka", + 3 => "Maulana", + 4 => "Mu'allima", + 5 => "Naquiba", + 6 => "Qadiya", + 7 => "Mushira", + 8 => "Amira", + 9 => "Malika", + 10 => "Sultana", + _ => null, + }; + + /// AllegianceSystem::GetShoMaleTitle @0x005B8000. Ranks 7 and 9 + /// resolve through data-literal indirections (&data_7e6ef8 / + /// &data_7e6eec) rather than inline C string literals — PE + /// byte-read as "Kou" (4b 6f 75 00) and "Ou" (4f 75 00 00) + /// respectively, matching published AC Sho lore exactly. + private static string? GetShoMaleTitle(int rank) => rank switch + { + 1 => "Jinin", + 2 => "Jo-chueh", + 3 => "Nan-chueh", + 4 => "Shi-chueh", + 5 => "Ta-chueh", + 6 => "Kun-chueh", + 7 => "Kou", + 8 => "Taikou", + 9 => "Ou", + 10 => "Koutei", + _ => null, + }; + + /// AllegianceSystem::GetShoFemaleTitle @0x005B8110. Rank 7 shares + /// the same &data_7e6ef8 ("Kou") indirection as the male + /// table; rank 9 is a plain inline literal here ("Jo-ou"), unlike the + /// male table's rank 9. + private static string? GetShoFemaleTitle(int rank) => rank switch + { + 1 => "Jinin", + 2 => "Jo-chueh", + 3 => "Nan-chueh", + 4 => "Shi-chueh", + 5 => "Ta-chueh", + 6 => "Kun-chueh", + 7 => "Kou", + 8 => "Taikou", + 9 => "Jo-ou", + 10 => "Koutei", + _ => null, + }; + + /// AllegianceSystem::GetViamontianMaleTitle @0x005B8220 + private static string? GetViamontianMaleTitle(int rank) => rank switch + { + 1 => "Squire", + 2 => "Banner", + 3 => "Baron", + 4 => "Viscount", + 5 => "Count", + 6 => "Marquis", + 7 => "Duke", + 8 => "Grand Duke", + 9 => "King", + 10 => "High King", + _ => null, + }; + + /// AllegianceSystem::GetViamontianFemaleTitle @0x005B8330 + private static string? GetViamontianFemaleTitle(int rank) => rank switch + { + 1 => "Dame", + 2 => "Banner", + 3 => "Baroness", + 4 => "Viscountess", + 5 => "Countess", + 6 => "Marquise", + 7 => "Duchess", + 8 => "Grand Duchess", + 9 => "Queen", + 10 => "High Queen", + _ => null, + }; + + /// AllegianceSystem::GetShadowboundMaleTitle @0x005B8440. Serves + /// heritage ids 5 (Shadowbound) AND 0xA (Penumbraen alias). + private static string? GetShadowboundMaleTitle(int rank) => rank switch + { + 1 => "Tenebrous", + 2 => "Shade", + 3 => "Squire", + 4 => "Knight", + 5 => "Void Knight", + 6 => "Void Lord", + 7 => "Duke", + 8 => "Archduke", + 9 => "Highborn", + 10 => "King", + _ => null, + }; + + /// AllegianceSystem::GetShadowboundFemaleTitle @0x005B8550. Serves + /// heritage ids 5 (Shadowbound) AND 0xA (Penumbraen alias). + private static string? GetShadowboundFemaleTitle(int rank) => rank switch + { + 1 => "Tenebrous", + 2 => "Shade", + 3 => "Squire", + 4 => "Knight", + 5 => "Void Knight", + 6 => "Void Lady", + 7 => "Duchess", + 8 => "Archduchess", + 9 => "Highborn", + 10 => "Queen", + _ => null, + }; + + /// AllegianceSystem::GetGearknightMaleTitle @0x005B8660. Gearknight + /// authors ONLY this function — dispatches BOTH + /// gender branches for heritage 6 here. Rank 8 resolves through a + /// data-literal indirection (&data_7e7034), PE byte-read as + /// "Dux" (44 75 78 00). + private static string? GetGearknightMaleTitle(int rank) => rank switch + { + 1 => "Tribunus", + 2 => "Praefectus", + 3 => "Optio", + 4 => "Centurion", + 5 => "Principes", + 6 => "Legatus", + 7 => "Consul", + 8 => "Dux", + 9 => "Secondus", + 10 => "Primus", + _ => null, + }; + + /// AllegianceSystem::GetTumerokMaleTitle @0x005B8770. Tumerok + /// authors ONLY this function — dispatches BOTH + /// gender branches for heritage 7 here. Ranks 3/6/10 resolve through + /// data-literal indirections, PE byte-read as "Ona" (4f 6e 61 00), + /// "Rea" (52 65 61 00), and "Tah" (54 61 68 00) + /// respectively. + private static string? GetTumerokMaleTitle(int rank) => rank switch + { + 1 => "Xutua", + 2 => "Tuona", + 3 => "Ona", + 4 => "Nuona", + 5 => "Turea", + 6 => "Rea", + 7 => "Nurea", + 8 => "Kauh", + 9 => "Sutah", + 10 => "Tah", + _ => null, + }; + + /// AllegianceSystem::GetLugianFemaleTitle @0x005B8880. Lugian + /// authors ONLY this function — dispatches BOTH + /// gender branches for heritage 8 here. + private static string? GetLugianFemaleTitle(int rank) => rank switch + { + 1 => "Laigus", + 2 => "Raigus", + 3 => "Amploth", + 4 => "Arintoth", + 5 => "Obeloth", + 6 => "Lithos", + 7 => "Kantos", + 8 => "Gigas", + 9 => "Extas", + 10 => "Tiatus", + _ => null, + }; + + /// AllegianceSystem::GetEmpyreanMaleTitle @0x005B8990 + private static string? GetEmpyreanMaleTitle(int rank) => rank switch + { + 1 => "Ensign", + 2 => "Corporal", + 3 => "Lieutenant", + 4 => "Commander", + 5 => "Captain", + 6 => "Commodore", + 7 => "Admiral", + 8 => "Warlord", + 9 => "Ipharsin", + 10 => "Aulin", + _ => null, + }; + + /// AllegianceSystem::GetEmpyreanFemaleTitle @0x005B8AA0 + private static string? GetEmpyreanFemaleTitle(int rank) => rank switch + { + 1 => "Ensign", + 2 => "Corporal", + 3 => "Lieutenant", + 4 => "Commander", + 5 => "Captain", + 6 => "Commodore", + 7 => "Admiral", + 8 => "Warlord", + 9 => "Ipharsia", + 10 => "Aulia", + _ => null, + }; + + /// AllegianceSystem::GetUndeadMaleTitle @0x005B8BB0 + private static string? GetUndeadMaleTitle(int rank) => rank switch + { + 1 => "Neophyte", + 2 => "Acolyte", + 3 => "Adept", + 4 => "Esquire", + 5 => "Squire", + 6 => "Knight", + 7 => "Count", + 8 => "Viscount", + 9 => "Highness", + 10 => "Annointed", + _ => null, + }; + + /// AllegianceSystem::GetUndeadFemaleTitle @0x005B8CC0 + private static string? GetUndeadFemaleTitle(int rank) => rank switch + { + 1 => "Neophyte", + 2 => "Acolyte", + 3 => "Adept", + 4 => "Esquire", + 5 => "Squire", + 6 => "Knight", + 7 => "Countess", + 8 => "Viscountess", + 9 => "Highness", + 10 => "Annointed", + _ => null, + }; +} diff --git a/src/AcDream.App/UI/Layout/AppraisalUiController.cs b/src/AcDream.App/UI/Layout/AppraisalUiController.cs index 0cf1b56f..05d6f9e2 100644 --- a/src/AcDream.App/UI/Layout/AppraisalUiController.cs +++ b/src/AcDream.App/UI/Layout/AppraisalUiController.cs @@ -725,6 +725,7 @@ public sealed class AppraisalUiController : IRetainedPanelController SetText(0x10000151u, BuildCharacterTitleDisplay(p)); SetText(0x10000152u, BuildPlayerKillerDisplay(obj)); SetText(0x1000053Au, BuildAllegianceDisplay(p)); + _titleValue = BuildCharacterTitleBarName(obj, p); } else { @@ -825,6 +826,36 @@ public sealed class AppraisalUiController : IRetainedPanelController private static string BuildAllegianceDisplay(PropertyBundle p) => GetInt(p, 30u) >= 1 ? GetString(p, 47u) : string.Empty; + /// + /// Campaign AS slice AS5 (retires AP-109's title-bar residual): the char + /// path's title-bar OVERWRITE, AllegianceData::GetFullName + /// @0x005B6950 called from CharExamineUI::SetAppraiseInfo + /// (`@0x004b4c8c`-`@0x004b4cec`, AFTER 's String + /// 52/stack-count composition already ran in ). + /// Unconditional at the call site — retail's null check there guards only + /// the destination WIDGET, not the rank; the "plain name when rankless" + /// behavior lives inside GetFullName/GetTitle themselves. + /// Rank/heritage/gender read straight off the APPRAISAL bundle (ruling + /// R8: PropertyInt + /// (0x1E), never RuntimeAllegianceState, which carries a DIFFERENT + /// UI's own rank). The appended name reuses + /// — the + /// SAME "appropriate name" resolver retail's + /// ACCWeenieObject::GetObjectName(cur_weenobj, NAME_APPROPRIATE, 0) + /// call resolves at that exact call site; String 52 (ruling R1, absent + /// for players) is deliberately bypassed here exactly as retail bypasses + /// it — the overwrite recomputes the name independently of + /// 's own String-52-then-fallback chain. + /// + private string BuildCharacterTitleBarName(ClientObject obj, PropertyBundle p) + { + int rank = GetInt(p, AllegianceRankTitleTable.AllegianceRankPropertyId); + int heritageGroup = GetInt(p, CharacterIdentityText.HeritageGroupPropertyId); + int gender = GetInt(p, CharacterIdentityText.GenderPropertyId); + string name = _itemNames.ResolveAppropriateName(obj); + return AllegianceRankTitleTable.ComposeFullName(rank, heritageGroup, gender, name); + } + /// /// Rebuilds both authored appraisal lists from a freshly parsed response. /// selects between the CharExamineUI diff --git a/src/AcDream.App/UI/Layout/CharacterIdentityText.cs b/src/AcDream.App/UI/Layout/CharacterIdentityText.cs index d1339159..1632d933 100644 --- a/src/AcDream.App/UI/Layout/CharacterIdentityText.cs +++ b/src/AcDream.App/UI/Layout/CharacterIdentityText.cs @@ -12,8 +12,8 @@ namespace AcDream.App.UI.Layout; /// matched it. /// /// -/// Name-line ruling (CT4, 2026-08-24; corrected at the CT4 fix round, -/// 2026-08-25 — AP-109). Retail's NAME line +/// Name-line ruling (CT4, 2026-08-24; CLOSED at Campaign AS slice AS5, +/// 2026-08-25 — retires AP-109). Retail's NAME line /// (AllegianceData::GetFullName @0x005b6950) prefixes an allegiance /// RANK title ("<RankTitle> <Name>", same space separator, PE-read /// @data_794098) when AllegianceSystem::GetTitle(rank, heritage, gender) @@ -24,26 +24,18 @@ namespace AcDream.App.UI.Layout; /// class carries a numerically-equivalent rank for a DIFFERENT UI /// (SocialAllegiancePageController, which has no qualities-bundle /// access of its own); -/// already reads every other header property straight off -/// props.GetInt(...), so a future port reads -/// props.GetInt(0x1Eu) directly instead. The STRING half is missing: -/// GetTitle's own dispatch switch (read directly, not estimated) has -/// exactly 17 Get*Title functions, not 22 — Gearknight/Tumerok author -/// only a MALE function (reused for both genders' dispatch branches) and -/// Lugian only a FEMALE one (likewise reused both ways), so 11 heritages -/// produce 17 functions; Olthoi/OlthoiAcid have none at all (the dispatch's -/// own unsigned range check excludes heritage ids 12/13). Each function is a -/// rank-indexed switch over ~10 HARDCODED literal strings (Aluvian male: -/// "Yeoman"/"Baronet"/"Baron"/"Reeve"/"Thane"/"Ealdor"/"Duke"/"Aetheling"/ -/// "King"/"High King" — verbatim from the decomp, not DAT-resolved, not -/// guessed) — roughly 170 title strings total. That is not "reasonable -/// size" for one slice on top of its other work, so -/// 's Name label ships the -/// PLAIN-NAME case only (matching the owner's own retail screenshot, a -/// rankless character, and every current test character). The missing -/// rank-prefix path is registered -/// (docs/architecture/retail-divergence-register.md, AP-109) rather -/// than silently omitted. +/// reads every other header property straight off +/// props.GetInt(...), and now the rank-prefix too. The 17-function +/// Get*Title table (Gearknight/Tumerok author only a MALE function, +/// reused for both genders' dispatch branches; Lugian only a FEMALE one, +/// likewise reused both ways; Olthoi/OlthoiAcid excluded by the dispatch's +/// own unsigned range check) is ported verbatim at +/// , whose class remarks carry the +/// full per-function address citations and the two independently-verified +/// decomp call sites. 's Name +/// label now composes through +/// via +/// . /// internal static class CharacterIdentityText { diff --git a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs index 6191d22a..bec44426 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs @@ -164,7 +164,20 @@ public sealed class CharacterSheetProvider return new CharacterSheet { - Name = CharacterName(), + // Campaign AS slice AS5 (retires AP-109): retail's NAME line + // (gmStatManagementUI::UpdateCharacterInfo @0x004F0770, + // @0x004f0807-@0x004f0895) prefixes AllegianceData::GetFullName + // @0x005B6950's rank title exactly like the examination window's + // title bar (AppraisalUiController.BuildCharacterTitleBarName) — + // same GetFullName port, same "plain name when rankless" + // fallback. Rank/heritage/gender come straight off THIS bundle + // (props.GetInt), per AP-109's own correction — never + // RuntimeAllegianceState. + Name = AllegianceRankTitleTable.ComposeFullName( + props.GetInt(AllegianceRankTitleTable.AllegianceRankPropertyId), + props.GetInt(CharacterIdentityText.HeritageGroupPropertyId), + props.GetInt(CharacterIdentityText.GenderPropertyId), + CharacterName()), Level = displayLevel, Gender = CharacterIdentityText.GenderDisplayName( props.GetInt(CharacterIdentityText.GenderPropertyId)), diff --git a/tests/AcDream.App.Tests/UI/Layout/AllegianceRankTitleTableTests.cs b/tests/AcDream.App.Tests/UI/Layout/AllegianceRankTitleTableTests.cs new file mode 100644 index 00000000..7b546d4e --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/AllegianceRankTitleTableTests.cs @@ -0,0 +1,248 @@ +using AcDream.App.UI.Layout; +using Xunit; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign AS slice AS5 conformance tests for +/// — golden values transcribed directly from AllegianceSystem::GetTitle +/// @0x005B8DD0's dispatch and its 17 Get*Title functions (see the +/// class remarks there for full address citations). Gender: 1 = male, +/// 2 = female. Heritage ids per : +/// 1 Aluvian, 2 Gharu'ndim, 3 Sho, 4 Viamontian, 5 Shadowbound/Umbraen, +/// 6 Gearknight, 7 Tumerok, 8 Lugian, 9 Empyrean, 10 (0xA) Penumbraen +/// (Shadowbound alias), 11 (0xB) Undead, 12/13 Olthoi/OlthoiAcid (excluded). +/// +public sealed class AllegianceRankTitleTableTests +{ + // ── Per-function spot checks: first / a middle / last rank each. ─────── + + [Theory] + [InlineData(1, "Yeoman")] + [InlineData(5, "Thane")] + [InlineData(10, "High King")] + public void AluvianMale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 1, gender: 1)); + + [Theory] + [InlineData(1, "Yeoman")] + [InlineData(3, "Baroness")] // diverges from male's "Baron" at rank 3 + [InlineData(10, "High Queen")] + public void AluvianFemale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 1, gender: 2)); + + [Theory] + [InlineData(1, "Sayyid")] + [InlineData(5, "Naquib")] + [InlineData(10, "Sultan")] + public void GharundimMale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 2, gender: 1)); + + [Theory] + [InlineData(1, "Sayyida")] + [InlineData(5, "Naquiba")] + [InlineData(10, "Sultana")] + public void GharundimFemale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 2, gender: 2)); + + /// Ranks 7 and 9 resolve through PE-byte-recovered data-literal + /// indirections in the decomp ("Kou", "Ou") — spot-checked explicitly, + /// not just the first/last ranks. + [Theory] + [InlineData(1, "Jinin")] + [InlineData(5, "Ta-chueh")] + [InlineData(7, "Kou")] + [InlineData(9, "Ou")] + [InlineData(10, "Koutei")] + public void ShoMale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 3, gender: 1)); + + /// Rank 9 diverges from the male table ("Jo-ou" vs "Ou"); rank 7 + /// shares the male table's "Kou" indirection. + [Theory] + [InlineData(1, "Jinin")] + [InlineData(7, "Kou")] + [InlineData(9, "Jo-ou")] + [InlineData(10, "Koutei")] + public void ShoFemale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 3, gender: 2)); + + [Theory] + [InlineData(1, "Squire")] + [InlineData(5, "Count")] + [InlineData(10, "High King")] + public void ViamontianMale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 4, gender: 1)); + + [Theory] + [InlineData(1, "Dame")] + [InlineData(5, "Countess")] + [InlineData(10, "High Queen")] + public void ViamontianFemale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 4, gender: 2)); + + [Theory] + [InlineData(1, "Tenebrous")] + [InlineData(5, "Void Knight")] + [InlineData(10, "King")] + public void ShadowboundMale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 5, gender: 1)); + + [Theory] + [InlineData(1, "Tenebrous")] + [InlineData(6, "Void Lady")] // diverges from male's "Void Lord" at rank 6 + [InlineData(10, "Queen")] + public void ShadowboundFemale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 5, gender: 2)); + + /// Heritage id 0xA (Penumbraen) ALIASES to the Shadowbound + /// functions on both gender dispatch branches — identical results to + /// heritage 5. + [Theory] + [InlineData(1, 1, "Tenebrous")] + [InlineData(2, 1, "Tenebrous")] + [InlineData(1, 6, "Void Lord")] + [InlineData(2, 6, "Void Lady")] + public void Penumbraen_AliasesShadowbound(int gender, int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 0xA, gender: gender)); + + [Fact] + public void Penumbraen_MatchesShadowboundExactlyAcrossAllRanksAndGenders() + { + for (int gender = 1; gender <= 2; gender++) + for (int rank = 1; rank <= 10; rank++) + { + Assert.Equal( + AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 5, gender), + AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 0xA, gender)); + } + } + + /// Gearknight authors only a MALE Get*Title function; + /// dispatches BOTH gender + /// branches to it for heritage 6. + [Theory] + [InlineData(1, "Tribunus")] + [InlineData(5, "Principes")] + [InlineData(8, "Dux")] // PE-byte-recovered data-literal indirection + [InlineData(10, "Primus")] + public void Gearknight_MaleFunctionReusedForBothGenders(int rank, string expected) + { + Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 6, gender: 1)); + Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 6, gender: 2)); + } + + /// Tumerok authors only a MALE Get*Title function; + /// dispatched for both genders on heritage 7. + [Theory] + [InlineData(1, "Xutua")] + [InlineData(3, "Ona")] // PE-byte-recovered data-literal indirection + [InlineData(6, "Rea")] // PE-byte-recovered data-literal indirection + [InlineData(10, "Tah")] // PE-byte-recovered data-literal indirection + public void Tumerok_MaleFunctionReusedForBothGenders(int rank, string expected) + { + Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 7, gender: 1)); + Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 7, gender: 2)); + } + + /// Lugian authors only a FEMALE Get*Title function; + /// dispatched for both genders on heritage 8. + [Theory] + [InlineData(1, "Laigus")] + [InlineData(5, "Obeloth")] + [InlineData(10, "Tiatus")] + public void Lugian_FemaleFunctionReusedForBothGenders(int rank, string expected) + { + Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 8, gender: 1)); + Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 8, gender: 2)); + } + + [Theory] + [InlineData(1, "Ensign")] + [InlineData(5, "Captain")] + [InlineData(10, "Aulin")] + public void EmpyreanMale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 9, gender: 1)); + + [Theory] + [InlineData(1, "Ensign")] + [InlineData(9, "Ipharsia")] // diverges from male's "Ipharsin" + [InlineData(10, "Aulia")] + public void EmpyreanFemale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 9, gender: 2)); + + [Theory] + [InlineData(1, "Neophyte")] + [InlineData(7, "Count")] + [InlineData(8, "Viscount")] + [InlineData(10, "Annointed")] + public void UndeadMale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 0xB, gender: 1)); + + [Theory] + [InlineData(1, "Neophyte")] + [InlineData(7, "Countess")] // diverges from male's "Count" + [InlineData(8, "Viscountess")] // diverges from male's "Viscount" + [InlineData(10, "Annointed")] + public void UndeadFemale_MatchesDecomp(int rank, string expected) + => Assert.Equal(expected, AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 0xB, gender: 2)); + + // ── Dispatch bounds and exclusions. ───────────────────────────────────── + + [Theory] + [InlineData(12)] // Olthoi + [InlineData(13)] // OlthoiAcid + [InlineData(0)] + [InlineData(-1)] + public void Heritage_OutsideRange_ReturnsNullForBothGenders(int heritageGroup) + { + Assert.Null(AllegianceRankTitleTable.GetTitle(rank: 1, heritageGroup, gender: 1)); + Assert.Null(AllegianceRankTitleTable.GetTitle(rank: 1, heritageGroup, gender: 2)); + } + + [Theory] + [InlineData(0)] + [InlineData(3)] + [InlineData(-1)] + public void Gender_Unrecognized_ReturnsNull(int gender) + => Assert.Null(AllegianceRankTitleTable.GetTitle(rank: 1, heritageGroup: 1, gender)); + + /// Every one of the 17 functions shares retail's identical + /// unsigned bounds test (rank - 1) > 9 — rank 0 (wraps huge) and + /// any rank > 10 both resolve to "no title", uniformly. + [Theory] + [InlineData(0)] + [InlineData(11)] + [InlineData(-1)] + [InlineData(int.MaxValue)] + public void Rank_OutsideOneToTen_ReturnsNull(int rank) + => Assert.Null(AllegianceRankTitleTable.GetTitle(rank, heritageGroup: 1, gender: 1)); + + // ── AllegianceData::GetFullName composition. ──────────────────────────── + + [Fact] + public void ComposeFullName_ValidRank_PrefixesTitleWithSingleSpace() + { + string result = AllegianceRankTitleTable.ComposeFullName( + rank: 3, heritageGroup: 1, gender: 2, name: "Aluvia"); + Assert.Equal("Baroness Aluvia", result); + } + + [Theory] + [InlineData(0)] // rank absent / zero + [InlineData(11)] // rank out of range + public void ComposeFullName_NoTitleResolved_ReturnsPlainNameUnmodified(int rank) + { + string result = AllegianceRankTitleTable.ComposeFullName( + rank, heritageGroup: 1, gender: 1, name: "Somebody"); + Assert.Equal("Somebody", result); + } + + [Fact] + public void ComposeFullName_UnrecognizedHeritage_ReturnsPlainName() + { + string result = AllegianceRankTitleTable.ComposeFullName( + rank: 5, heritageGroup: 12 /* Olthoi */, gender: 1, name: "Xarabydun"); + Assert.Equal("Xarabydun", result); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs index 7eb2f7a2..d70c862a 100644 --- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs @@ -459,6 +459,115 @@ public sealed class AppraisalUiControllerTests Assert.Equal("The Empire", HeaderText(layout, 0x1000053Au)); } + // ── Campaign AS slice AS5: title-bar allegiance rank-title prefix ───── + // Ground truth: docs/research/2026-08-25-campaign-as-ground-truth.md + // §2a's title-bar row, gap G9, ruling R8. Retires register row AP-109. + + [Fact] + public void CharacterResponse_TitleBarPrefixesAllegianceRankTitle() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Dww", + Type = ItemType.Creature, + }); + using var interaction = NewInteraction(objects, []); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { })!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + var properties = new PropertyBundle(); + properties.Strings[5u] = "Template"; // Character-view marker + properties.Ints[0x1Eu] = 3; // AllegianceRank + properties.Ints[0xBCu] = 1; // HeritageGroup: Aluvian + properties.Ints[0x71u] = 2; // Gender: Female + + Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile()))); + Assert.Equal(AppraisalView.Character, controller.ActiveView); + // Aluvian female rank 3 = "Baroness" (AllegianceRankTitleTableTests + // pins the table itself); GetFullName's single-space separator. + Assert.Equal("Baroness Dww", HeaderText(layout, AppraisalUiController.TitleId)); + } + + [Fact] + public void CharacterResponse_TitleBarPlainNameWhenRankAbsent() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Dww", + Type = ItemType.Creature, + }); + using var interaction = NewInteraction(objects, []); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { })!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + var properties = new PropertyBundle(); + properties.Strings[5u] = "Template"; // Character-view marker, no AllegianceRank. + + Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile()))); + Assert.Equal(AppraisalView.Character, controller.ActiveView); + Assert.Equal("Dww", HeaderText(layout, AppraisalUiController.TitleId)); + } + + /// Regression pin: CreatureExamineUI (monsters) never + /// calls AllegianceData::GetFullName — the overwrite is scoped to + /// the character branch of ApplyCreature only, even when + /// rank/heritage/gender-shaped properties happen to be present. + [Fact] + public void CreatureResponse_TitleBarNeverGetsAllegianceRankPrefix() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Drudge", + Type = ItemType.Creature, + }); + using var interaction = NewInteraction(objects, []); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { })!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + var properties = new PropertyBundle(); + // No String 5 / Int 0x105 -> monster path, NOT the character view. + properties.Ints[0x1Eu] = 3; + properties.Ints[0xBCu] = 1; + properties.Ints[0x71u] = 2; + + Assert.True(controller.Apply(Parsed(properties, MinimalCreatureProfile()))); + Assert.Equal(AppraisalView.Creature, controller.ActiveView); + Assert.Equal("Drudge", HeaderText(layout, AppraisalUiController.TitleId)); + } + [Fact] public void CharacterResponse_HeritageFallsBackToCreatureTypeWhenGroupIsZero() { diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs index 623f2da6..efef3164 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs @@ -104,6 +104,36 @@ public sealed class CharacterSheetProviderTests Assert.Equal(90L, sheet.AttributeRaise10Costs[0]); } + // ── Campaign AS slice AS5: name-line allegiance rank-title prefix ────── + // Retires register row AP-109. Same GetFullName port + rank-property id + // (props.GetInt(0x1E)) as the examination window's title bar + // (AppraisalUiControllerTests.CharacterResponse_TitleBarPrefixes*). + + [Fact] + public void BuildSheet_WithAllegianceRank_PrefixesNameWithRankTitle() + { + var h = new Harness(); + var player = h.AddPlayerObject(); + player.Properties.Ints[0x1Eu] = 3; // AllegianceRank + player.Properties.Ints[0xBCu] = 1; // HeritageGroup: Aluvian + player.Properties.Ints[0x71u] = 2; // Gender: Female + + var sheet = h.Provider.BuildSheet(); + + Assert.Equal("Baroness Testy", sheet.Name); + } + + [Fact] + public void BuildSheet_WithoutAllegianceRank_NameStaysPlain() + { + var h = new Harness(); + h.AddPlayerObject(); + + var sheet = h.Provider.BuildSheet(); + + Assert.Equal("Testy", sheet.Name); + } + [Fact] public void BuildSheet_AfterLiveInt64Updates_RefreshesBothXpWindowsAndMeter() { From 9f3e3263743aa49bfdc81be0d72c0e0546d1b8dc Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 10:30:53 +0200 Subject: [PATCH 73/89] =?UTF-8?q?fix(ui):=20Campaign=20AS=20AS5=20fix=20ro?= =?UTF-8?q?und=20=E2=80=94=20"retires=20AP-109"=20corrected=20to=20"narrow?= =?UTF-8?q?s"=20at=206=20sites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AS5 review verified the port exhaustively (170/170 strings, 20 dispatch arms, 17 bounds tests, both call sites) and found one docs-class defect: five code comments plus the plan slice text claimed AP-109 was RETIRED while the register correctly keeps the row ACTIVE-narrowed (CT4 FormatXp GetNumberFormatA approximation sliver survives). Comment-only edits; compile-checked; no behavior change. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-08-25-assess-window-parity-campaign.md | 9 ++++++--- src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs | 2 +- src/AcDream.App/UI/Layout/CharacterIdentityText.cs | 2 +- src/AcDream.App/UI/Layout/CharacterSheetProvider.cs | 2 +- .../UI/Layout/AppraisalUiControllerTests.cs | 2 +- .../UI/Layout/CharacterSheetProviderTests.cs | 2 +- 6 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/plans/2026-08-25-assess-window-parity-campaign.md b/docs/plans/2026-08-25-assess-window-parity-campaign.md index 627dc790..ad1f0874 100644 --- a/docs/plans/2026-08-25-assess-window-parity-campaign.md +++ b/docs/plans/2026-08-25-assess-window-parity-campaign.md @@ -118,8 +118,11 @@ a ruling requires a plan-doc amendment, not an implementer judgment call. reused, Penumbraen aliases Shadowbound, Olthoi excluded by the unsigned range check) + `AllegianceData::GetFullName @0x005B6950`; wire the examination title bar (rank from `props.GetInt(0x1E)`, ruling R8) AND - the character panel's name line (closing AP-109's residual). Register: - retire AP-109 in this commit. + the character panel's name line (closing AP-109's rank-prefix residual). + Register: NARROW AP-109 in this commit (CORRECTED at the AS5 review — + retiring would have deleted a live open item: CT4's FormatXp + `GetNumberFormatA` approximation sliver survives as the row's sole + remaining item, so the row stays active-narrowed). - **AS6 — connected gate script (Fable).** User-driven script `docs/research/2026-08-25-campaign-as-test-script.md`; two-client where needed (allegiance/fellowship/PK lines, deception-failure rendering); @@ -140,5 +143,5 @@ round → narrow re-review → REVIEW-CLOSED. | AS2 | **REVIEW-CLOSED 2026-08-25** | `f8a22589` / `cc5290af` | port exact per dual-lens review; 6 findings (docs/test/refactor) fixed; +AD-114 (animated paperdoll), +AD-115 (title clear-vs-stale), PK bits promoted to `PublicWeenieFlags`; carried follow-up chip: 3 more Core PK-bit copies | | AS3 | **REVIEW-CLOSED 2026-08-25** | `1616cd3d` (no fix round) | APPROVE first pass — trio/legend/monster-path exact by offset-level decomp verification; R3 flattening theory disproven at source; ratings adjudication: pre-AS3 code already retail-exact. 5 NITs: 12 (legend-order comment), 11 (stronger refresh test) fold into AS4; 14/15 done in the close commit; 13 (geometry-keyed test helper) noted | | AS4 | **REVIEW-CLOSED 2026-08-25** | `4ade9b04` / `bf8f5b70` (docs-only fix) | port exact per dual-lens review (presence-gate adjudicated FOR the implementer at `InqInt @0x005B3830`; Time-in-Dereth = pre-existing `RetailDurationText @0x00565E10` port, correct reuse); fix round was oracle-doc corrections + records only, NO code change. True full-solution hermetic count 15,528 (the commit's 15,410 was a mis-report). **AS6 carry-note: the Society green/red colorIdx is MODEL-ONLY (ResolveColor no-op pending AP-110 FontInfo residual) — the gate script must NOT gate on row colors.** Pre-existing parallel-load flake surfaced (shadow-caster zero-alloc pin) — #442, unrelated to AS4 | -| AS5 | dispatched 2026-08-25 | — | | +| AS5 | fix round applied, re-review pending | `8f8c0c3a` + comment-fix commit | the campaign's most rigorously verified slice: 170/170 title strings confirmed (164 mechanical diff, 6 PE byte-decoded from the PDB-paired binary), all 20 dispatch arms, all 17 bounds tests, both call sites re-derived; zero behavioral findings. Fix round = "retires AP-109" → "narrows" at 5 comment sites + the plan (the FormatXp sliver keeps the row active) | | AS6 | pending AS5 review-close | — | | diff --git a/src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs b/src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs index 7b92545e..ad3c2c18 100644 --- a/src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs +++ b/src/AcDream.App/UI/Layout/AllegianceRankTitleTable.cs @@ -1,7 +1,7 @@ namespace AcDream.App.UI.Layout; /// -/// Campaign AS slice AS5 (2026-08-25, retires register row AP-109): the +/// Campaign AS slice AS5 (2026-08-25, narrows register row AP-109 — its FormatXp sliver survives): the /// retail allegiance rank-title table and its name-composition wrapper. /// /// Dispatch — AllegianceSystem::GetTitle @0x005B8DD0. diff --git a/src/AcDream.App/UI/Layout/CharacterIdentityText.cs b/src/AcDream.App/UI/Layout/CharacterIdentityText.cs index 1632d933..7511ba44 100644 --- a/src/AcDream.App/UI/Layout/CharacterIdentityText.cs +++ b/src/AcDream.App/UI/Layout/CharacterIdentityText.cs @@ -13,7 +13,7 @@ namespace AcDream.App.UI.Layout; /// /// /// Name-line ruling (CT4, 2026-08-24; CLOSED at Campaign AS slice AS5, -/// 2026-08-25 — retires AP-109). Retail's NAME line +/// 2026-08-25 — narrows AP-109; its FormatXp sliver survives). Retail's NAME line /// (AllegianceData::GetFullName @0x005b6950) prefixes an allegiance /// RANK title ("<RankTitle> <Name>", same space separator, PE-read /// @data_794098) when AllegianceSystem::GetTitle(rank, heritage, gender) diff --git a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs index bec44426..a938e8dc 100644 --- a/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs +++ b/src/AcDream.App/UI/Layout/CharacterSheetProvider.cs @@ -164,7 +164,7 @@ public sealed class CharacterSheetProvider return new CharacterSheet { - // Campaign AS slice AS5 (retires AP-109): retail's NAME line + // Campaign AS slice AS5 (narrows AP-109 — its FormatXp sliver survives): retail's NAME line // (gmStatManagementUI::UpdateCharacterInfo @0x004F0770, // @0x004f0807-@0x004f0895) prefixes AllegianceData::GetFullName // @0x005B6950's rank title exactly like the examination window's diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs index d70c862a..b496b206 100644 --- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs @@ -461,7 +461,7 @@ public sealed class AppraisalUiControllerTests // ── Campaign AS slice AS5: title-bar allegiance rank-title prefix ───── // Ground truth: docs/research/2026-08-25-campaign-as-ground-truth.md - // §2a's title-bar row, gap G9, ruling R8. Retires register row AP-109. + // §2a's title-bar row, gap G9, ruling R8. Closes AP-109's rank-prefix residual (the row survives, narrowed to its FormatXp sliver). [Fact] public void CharacterResponse_TitleBarPrefixesAllegianceRankTitle() diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs index efef3164..9697deae 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterSheetProviderTests.cs @@ -105,7 +105,7 @@ public sealed class CharacterSheetProviderTests } // ── Campaign AS slice AS5: name-line allegiance rank-title prefix ────── - // Retires register row AP-109. Same GetFullName port + rank-property id + // Closes AP-109's rank-prefix residual (row survives, narrowed). Same GetFullName port + rank-property id // (props.GetInt(0x1E)) as the examination window's title bar // (AppraisalUiControllerTests.CharacterResponse_TitleBarPrefixes*). From 87e98395612897e94683d01a0a8f8a722b5f43ea Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 10:33:49 +0200 Subject: [PATCH 74/89] =?UTF-8?q?docs(AS):=20AS5=20REVIEW-CLOSED;=20AS6=20?= =?UTF-8?q?gate=20script=20committed=20=E2=80=94=20Campaign=20AS=20impleme?= =?UTF-8?q?ntation=20COMPLETE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AS1-AS5 are all review-closed (AS5: 170/170 title strings verified, 6 of them PE byte-decoded; zero behavioral findings across the campaign after AS2). The AS6 connected-gate script is written and waits on the owner: docs/research/2026-08-25-campaign-as-test-script.md, carrying the two standing rulings (Society row colors are model-only pending AP-110 FontInfo — do not gate on them; the AD-114 animated paperdoll deviation is expected) and the R3 unconditional-legend retail side-by-side check. Also: the re-review's grep-hygiene tail — the last "retires AP-109" phrasing in AppraisalUiController.cs now reads "closes AP-109's title-bar residual", and the CT plan's stale "this campaign retires AP-109" intent line (never executed — CT4 narrowed) carries a dated correction. Branch remains unpushed per the owner's standing instruction. Co-Authored-By: Claude Fable 5 --- ...6-08-24-character-panel-parity-campaign.md | 8 +- ...026-08-25-assess-window-parity-campaign.md | 9 +- .../2026-08-25-campaign-as-test-script.md | 147 ++++++++++++++++++ .../UI/Layout/AppraisalUiController.cs | 2 +- 4 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 docs/research/2026-08-25-campaign-as-test-script.md diff --git a/docs/plans/2026-08-24-character-panel-parity-campaign.md b/docs/plans/2026-08-24-character-panel-parity-campaign.md index 76281ede..18204b83 100644 --- a/docs/plans/2026-08-24-character-panel-parity-campaign.md +++ b/docs/plans/2026-08-24-character-panel-parity-campaign.md @@ -4,8 +4,12 @@ **Execution model:** Fable plans and coordinates; Sonnet implements each slice; Opus runs the dual-lens review (retail-faithful + architectural) per slice, then a fix round. No pushes to gitea until the owner says so. -**Register:** this campaign retires AP-109 (inert Titles page) when CT3+CT4 -land; every deviation a slice introduces adds its row in the same commit. +**Register:** [STALE-INTENT CORRECTION 2026-08-25, at Campaign AS's AS5 +re-review: this retirement never happened — CT3/CT4 NARROWED AP-109 and +Campaign AS AS5 later closed its rank-prefix residual; the row remains +ACTIVE-narrowed to CT4's FormatXp sliver.] Original intent: retire AP-109 +(inert Titles page) when CT3+CT4 land; every deviation a slice introduces +adds its row in the same commit. ## Owner report (2026-08-24, screenshots on file) diff --git a/docs/plans/2026-08-25-assess-window-parity-campaign.md b/docs/plans/2026-08-25-assess-window-parity-campaign.md index ad1f0874..8818d348 100644 --- a/docs/plans/2026-08-25-assess-window-parity-campaign.md +++ b/docs/plans/2026-08-25-assess-window-parity-campaign.md @@ -1,6 +1,9 @@ # Campaign AS — assess/examination window retail parity (player targets) -**Status: EXECUTING — AS1 research synthesis complete 2026-08-25; slices final.** +**Status: IMPLEMENTATION COMPLETE 2026-08-25 — AS1–AS5 REVIEW-CLOSED, AS6 +gate script written (`docs/research/2026-08-25-campaign-as-test-script.md`). +ONLY the owner-driven connected gate remains. Branch NOT pushed — the owner +pushes on their word.** Owner report (2026-08-25, side-by-side screenshots, acdream vs retail, both assessing the player "Dww"): acdream's examination window on a PLAYER target @@ -143,5 +146,5 @@ round → narrow re-review → REVIEW-CLOSED. | AS2 | **REVIEW-CLOSED 2026-08-25** | `f8a22589` / `cc5290af` | port exact per dual-lens review; 6 findings (docs/test/refactor) fixed; +AD-114 (animated paperdoll), +AD-115 (title clear-vs-stale), PK bits promoted to `PublicWeenieFlags`; carried follow-up chip: 3 more Core PK-bit copies | | AS3 | **REVIEW-CLOSED 2026-08-25** | `1616cd3d` (no fix round) | APPROVE first pass — trio/legend/monster-path exact by offset-level decomp verification; R3 flattening theory disproven at source; ratings adjudication: pre-AS3 code already retail-exact. 5 NITs: 12 (legend-order comment), 11 (stronger refresh test) fold into AS4; 14/15 done in the close commit; 13 (geometry-keyed test helper) noted | | AS4 | **REVIEW-CLOSED 2026-08-25** | `4ade9b04` / `bf8f5b70` (docs-only fix) | port exact per dual-lens review (presence-gate adjudicated FOR the implementer at `InqInt @0x005B3830`; Time-in-Dereth = pre-existing `RetailDurationText @0x00565E10` port, correct reuse); fix round was oracle-doc corrections + records only, NO code change. True full-solution hermetic count 15,528 (the commit's 15,410 was a mis-report). **AS6 carry-note: the Society green/red colorIdx is MODEL-ONLY (ResolveColor no-op pending AP-110 FontInfo residual) — the gate script must NOT gate on row colors.** Pre-existing parallel-load flake surfaced (shadow-caster zero-alloc pin) — #442, unrelated to AS4 | -| AS5 | fix round applied, re-review pending | `8f8c0c3a` + comment-fix commit | the campaign's most rigorously verified slice: 170/170 title strings confirmed (164 mechanical diff, 6 PE byte-decoded from the PDB-paired binary), all 20 dispatch arms, all 17 bounds tests, both call sites re-derived; zero behavioral findings. Fix round = "retires AP-109" → "narrows" at 5 comment sites + the plan (the FormatXp sliver keeps the row active) | -| AS6 | pending AS5 review-close | — | | +| AS5 | **REVIEW-CLOSED 2026-08-25** | `8f8c0c3a` / `9f3e3263` | the campaign's most rigorously verified slice: 170/170 title strings confirmed (164 mechanical diff, 6 PE byte-decoded from the PDB-paired binary), all 20 dispatch arms, all 17 bounds tests, both call sites re-derived; zero behavioral findings. Fix round = "retires AP-109" → "narrows" at 5 comment sites + the plan (the FormatXp sliver keeps the row active); re-review also flagged + this close fixed the last "retires" phrasing (`AppraisalUiController.cs`) and the CT plan's stale retirement intent | +| AS6 | **DONE 2026-08-25 — script committed, WAITING ON THE OWNER'S DRIVE** | (this commit) | `docs/research/2026-08-25-campaign-as-test-script.md`; carries the two standing rulings (row colors model-only — do not gate; AD-114 paperdoll deviation expected) and the R3 legend retail side-by-side check | diff --git a/docs/research/2026-08-25-campaign-as-test-script.md b/docs/research/2026-08-25-campaign-as-test-script.md new file mode 100644 index 00000000..38e75ece --- /dev/null +++ b/docs/research/2026-08-25-campaign-as-test-script.md @@ -0,0 +1,147 @@ +# Campaign AS — connected gate script (AS6, user-driven) + +**Purpose:** live verification of the assess/examination-window parity +campaign (AS2–AS5) against ACE, retail side-by-side as the oracle. +Launch: the normal connected launch (`ACDREAM_RETAIL_UI=1`, live ACE at +`127.0.0.1:9000`, `ACDREAM_PAK_PATH=\artifacts\owner-gate\acdream-v5.pak`). +Sections marked **[TWO-CLIENT]** use the FA-campaign account pair +(`testaccount`/`+Acdream` + `testaccount2`/`+Horan`) — assess the OTHER +player. Where a second retail client is available, compare the same assess +performed from retail. + +**Two standing rulings, read before judging:** +- **Row COLORS are out of scope this gate.** The Society green/red (and any + buffed/debuffed row tint) is MODEL-ONLY today — `ResolveColor` renders + the authored default until AP-110's FontInfo-list residual lands. Judge + TEXT content, ordering, and presence only. +- **The paperdoll is our registered intentional deviation (AD-114):** ours + mirrors the target's live motion; retail's clone plays its own decoupled + idle cycle. A mismatch there is EXPECTED and correct. + +--- + +## 1. Header identity block (AS2) [TWO-CLIENT] + +Assess the other player. Under the title bar, four lines: + +1. **Gender+heritage**: " " composed (e.g. "Female + Aluvian") — not a raw property string. PASS: matches retail's line for + the same target. +2. **Title**: the target's CURRENT display title (e.g. "War Mage"). Have + the target change their display title (Character panel → Titles → Set + as Display Title), re-assess: the new title shows. No title set → + the line is empty (ours deliberately clears where retail can show a + stale previous target's title — register AD-115; do not fail us for + being cleaner than retail here). +3. **PK status**: "Non-Player Killer" for a normal character. (If a PK or + PKLite character is available, verify its variant.) +4. **Allegiance name**: shown only when the target is in a named + allegiance (sworn, rank ≥ 1); otherwise empty. +5. **Failed assess**: on the TARGET client enable Deception's + "Attempt to Deceive" option (retail Options → Character) if available + with a high-Deception target; a failed assess shows "???" attributes + BUT the four header lines above still render (identity rides even on + failure). Best-effort — needs a target whose Deception beats the + examiner's Assess Person. +6. **The invented literal is gone**: assess a MONSTER and fail (or any + monster assess) — the line that used to read "Assessment incomplete" + shows nothing, matching retail. + +## 2. Per-bodypart armor levels (AS3) [TWO-CLIENT] + +1. Target wearing armor: three rows appear — + "Head/Chest/Groin AL: a/b/c", "Bicep/Wrist/Hand AL: a/b/c", + "Thigh/Shin/Foot AL: a/b/c" — values matching retail's assess of the + same target (buffed values: cast an armor buff on the target and + re-assess; numbers rise). +2. Target naked (bank the armor): the three rows AND their leading blank + line disappear entirely. +3. Unenchantable coverage (if an unenchantable piece is available): the + affected group shows "*N" (N = the level without the sentinel). +4. **The R3 legend check (explicit retail side-by-side):** "* = + Unenchantable" renders as the LAST line of a player assess. Our port + shows it UNCONDITIONALLY (decomp-proven structure). Confirm retail + does the same on a player with NO starred values and again with NO + armor at all. If retail hides it in either case, report it — that + flips ruling R3 and we change ours. + +## 3. Ratings block (regression) + +On a target with ratings (augmented/geared): "Dmg/CritDmg Rating: x/y", +"Dmg/CritDmg Resist: x/y", "DoT/Life: Resist: x/y" rows as before, +each appearing only when its family is nonzero. Unchanged behavior — +spot-check only. + +## 4. Society / allegiance / fellowship rows (AS4) [TWO-CLIENT] + +1. **Fellowship**: form a fellowship (FA campaign flow), assess the other + member: "Fellowship: " row appears; disband → row gone on + re-assess. +2. **Allegiance cascade** (best-effort — ACE's swear-at-close-range quirk + #384 may block creating fresh vassals): assess a sworn character — + "Monarch:" (and "Patron:" when different, "Monarch/Patron:" when the + same person). Assess a MONARCH: "Alleg. Monarch:" + "N Follower(s)". +3. **Society** (only if a faction-joined character exists on this ACE): + "Society: " with the rank suffix (" ~ Initiate" … " ~ Master") + per the target's standing. TEXT only — ignore colors (standing ruling). + +## 5. Target-configurable extras (AS4) [TWO-CLIENT] + +On the TARGET client open Options → the retail Character tab and toggle +each "Allow others to see..." option, re-assessing from the other client +after each change (each row appears iff the target allows it — the server +gates; we render what arrives): + +| Toggle on target | Row | Expected value shape | +|---|---|---| +| Date of Birth | `Arrived in Dereth:` | server-formatted date, verbatim | +| Age | `Time in Dereth:` | retail duration format — bare units, e.g. "3mo 2d 5h 12m 40s"; zero components omitted except seconds | +| Chess Rank | `Chess Rank:` | number | +| Fishing Skill | `Fishing Skill:` | number | +| Number of Deaths | `Deaths:` | number; a deathless character shows "Has never died" as the value (label stays "Deaths:") | +| Number of Titles | `Titles Earned:` | number | + +PASS: each row toggles with its option, the labels match retail exactly, +and the full extras ORDER matches retail: Society → allegiance rows → +[blank] → armor trio → [blank] → ratings → [blank] → Fellowship → Arrived +→ Time → Chess → Fishing → Deaths → Titles → "* = Unenchantable". + +## 6. Title-bar allegiance rank prefix (AS5) [TWO-CLIENT, best-effort] + +Assess a sworn character with an allegiance rank: the WINDOW TITLE reads +" " (e.g. "Yeoman Horan") — heritage- and +gender-specific title from retail's table. The character panel (F9) name +line of YOUR OWN sworn character shows the same prefix. Unsworn/rank 0 → +plain name in both places. (Creating a fresh sworn pair may be blocked by +#384 — use any already-sworn character; otherwise mark SKIPPED.) + +## 7. Combat auto-refresh + +Enter combat mode with the exam window open on a player: the window +refreshes ~every 0.75 s; the AL trio, extras, and header lines persist +and update (buff the target's armor mid-watch: values change without +re-assessing manually). + +## 8. Regression sweep (5 minutes) + +- Monster assess: species line + stat rows + ratings as before; NO armor + trio, NO legend, nothing where the old invented literal was. +- Item assess: the full item report unchanged; inscription flow intact. +- Spell assess unchanged. +- The animated paperdoll mirrors the target's motion (our AD-114 + deviation — expected). +- Character panel (F9): CT-campaign behaviors intact (titles page, header + identity block, resize clamps 372–1000, scrollbar hand-off). + +--- + +## Report back + +Per section PASS/FAIL plus anything odd. The three answers that matter +most: +1. §5 — does every option-gated row toggle correctly with the target's + own options, in retail's exact order? +2. §2.4 — does retail show the "* = Unenchantable" legend unconditionally + (our R3 reading), or does it hide it in some case? +3. §1 — is the identity block exact against retail for the same target + (composition, title live-update, PK text)? diff --git a/src/AcDream.App/UI/Layout/AppraisalUiController.cs b/src/AcDream.App/UI/Layout/AppraisalUiController.cs index 05d6f9e2..c3da98be 100644 --- a/src/AcDream.App/UI/Layout/AppraisalUiController.cs +++ b/src/AcDream.App/UI/Layout/AppraisalUiController.cs @@ -827,7 +827,7 @@ public sealed class AppraisalUiController : IRetainedPanelController => GetInt(p, 30u) >= 1 ? GetString(p, 47u) : string.Empty; /// - /// Campaign AS slice AS5 (retires AP-109's title-bar residual): the char + /// Campaign AS slice AS5 (closes AP-109's title-bar residual): the char /// path's title-bar OVERWRITE, AllegianceData::GetFullName /// @0x005B6950 called from CharExamineUI::SetAppraiseInfo /// (`@0x004b4c8c`-`@0x004b4cec`, AFTER 's String From 65f6f5848a0a0c305b2befddb6c7deb06e66e975 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 12:58:29 +0200 Subject: [PATCH 75/89] =?UTF-8?q?fix(ui):=20Campaign=20AS=20gate=20fixes?= =?UTF-8?q?=20AS-GF1=20=E2=80=94=20extras-list=20overflow=20ruled=20OUT=20?= =?UTF-8?q?as=20a=20code=20defect;=20paperdoll=20regression=20not=20isolat?= =?UTF-8?q?ed,=20probe=20added=20instead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two owner-reported defects at the Campaign AS connected gate on the examination window (player targets): the animated paperdoll no longer renders at all, and a "reserved black rectangle" appears at the window's bottom with the character extras list clipped mid-row at default (310x400) window size. ROOT CAUSE — extras-list overflow (the "clipped mid-row" half of defect 2): NOT a code bug. AS3 (armor-level trio) and AS4 (society/allegiance/ configurable extras) grew the extras list past its DAT-authored 87px region (element 0x10000335) at the window's minimum size — a new hermetic regression test proves the worst-case combination (every AS3+AS4 addition at once) reaches 20 rows / 400px of content, a 4.6x overflow. But retail's own LayoutDesc authors NO scrollbar for this listbox either (ScrollbarElementId == 0, verified against both the committed fixture and a fresh tools/LayoutDump read of the live installed DAT — no drift), and the SAME test proves UiItemList's pre-existing, unmodified wheel-scroll handler (OnEvent's UiEventType.Scroll branch) already reveals every row on the next paint. A scrollbar-less, wheel-scrollable list clipped to its authored region until the user scrolls or resizes IS retail's own already-correctly- ported mechanism, not a regression — so no fix was made here. ROOT CAUSE — paperdoll / "black rectangle" (defect 1): NOT ISOLATED despite exhaustive investigation. Every file the Campaign AS diff touches (AppraisalUiController.cs, RetailUiRuntime.cs, CreatureAppraisalRows.cs, AllegianceRankTitleTable.cs, CharacterIdentityText.cs, CharacterSheetProvider.cs, InteractionRetainedUiComposition.cs, plus two unrelated mechanical PublicWeenieFlags-literal refactors) was reviewed in full against the pre-Campaign-AS baseline. The same worst-case regression test proves Apply/ApplyCreature/RebuildCreatureStats/BuildExtra never throw and always leave ActiveView == Character, CurrentObjectId != 0, and the viewport's full ancestor-visibility chain Visible == true — ruling out RetailCreatureAppraisalFrameView.TryGetVisibleTarget's first three gates. CreatureAppraisalPresentation.cs and LivePresentationComposition.cs (the entire render-time viewport pipeline) are byte-for-byte unchanged across the whole 974fe88a..87e98395 window. UiViewport.OnDraw draws NOTHING (not black) when its TextureSlot is unassigned, and the creaturePanel's own full-panel backdrop (0x10000141) is what would show through instead — the most likely explanation tying both defects to ONE underlying condition, but its exact trigger (TryGetVisibleTarget's CurrentObjectId check, or TrySynchronize's live-entity/mesh-availability check) lies in code nothing in Campaign AS touches, and could not be reproduced hermetically (needs a live entity + a live examine exchange). Filed #443 with the full investigation trail. Added a temporary, state-change-gated diagnostic probe (ACDREAM_PROBE_CREATURE_APPRAISAL_ VIEWPORT=1, CreatureAppraisalViewportDiagnostics) at both TryGetVisibleTarget and TrySynchronize so the next live repro pinpoints the exact failing reason instead of another guess. Per CLAUDE.md's "no workarounds without explicit approval" and the investigation mode's own escape hatch ("if you cannot root-cause, say what runtime evidence you need instead of shipping a guess"), no behavioral fix was shipped for defect 1. Tests: AcDream.App.Tests hermetic filter 6,337/0; full-solution hermetic suite 15,612/0 (all 14 projects green, including the known #442 flake, which did not trip this run). Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 85 ++++++++++++ docs/launch-options.md | 1 + .../CreatureAppraisalPresentation.cs | 43 +++++- .../CreatureAppraisalViewportDiagnostics.cs | 52 +++++++ .../UI/Layout/AppraisalUiControllerTests.cs | 128 ++++++++++++++++++ 5 files changed, 302 insertions(+), 7 deletions(-) create mode 100644 src/AcDream.App/Rendering/CreatureAppraisalViewportDiagnostics.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 7db802e7..78208f5f 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,91 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #443 — Examination window: player-target paperdoll viewport renders nothing (AS-GF1) + +**Status:** OPEN — probe added, root cause NOT isolated. +**Component:** examination window / creature-appraisal private viewport. +**Filed:** 2026-08-25, AS-GF1 gate-fix session. + +Owner report at the Campaign AS connected gate: the animated 3-D paperdoll +in the examination window (LayoutDesc `0x2100006B` element `0x10000148`) +worked correctly at baseline `974fe88a` (praised the same session) and was +gone by `87e98395` (ten commits later, AS2-AS5). The same gate also +reported "a reserved black rectangle at the window's bottom" and mid-row +clipping in the character extras list at the window's default (minimum +310x400) size, fixed by dragging the window taller. + +**Exhaustive investigation (this session) found NO code bug in the +Campaign AS diff for either symptom:** + +- Every file the AS2-AS5 window touches + (`AppraisalUiController.cs`, `RetailUiRuntime.cs`, + `CreatureAppraisalRows.cs`, `AllegianceRankTitleTable.cs` (new), + `CharacterIdentityText.cs`, `CharacterSheetProvider.cs`, + `InteractionRetainedUiComposition.cs`, plus two unrelated + mechanical `PublicWeenieFlags`-literal refactors) was read in full + against the pre-Campaign-AS baseline. +- A new hermetic regression test, + `AppraisalUiControllerTests.CharacterResponse_WorstCaseExtrasCombination_DoesNotThrowAndViewportGateStaysOpen`, + applies EVERY AS3+AS4 extras-list addition at once (armor-level trio, + society, allegiance cascade, ratings, all seven configurable extras) — + the combination none of the individual AS3/AS4 tests exercise together — + through the REAL DAT-derived examination layout and REAL row templates. + It proves `Apply`/`ApplyCreature`/`RebuildCreatureStats`/`BuildExtra` + never throw and always leave `ActiveView == Character`, + `CurrentObjectId != 0`, and the viewport's full ancestor-visibility + chain (`creaturePanel` → root) `Visible == true`, even in this worst + case. `RetailCreatureAppraisalFrameView.TryGetVisibleTarget`'s first + three gates (ActiveView, windowFrame visible, viewport visible) are + therefore unaffected. +- The SAME test also proves the extras list's clip-then-wheel-scroll + behavior is correct and unaffected: `UiItemList.OnEvent`'s + `UiEventType.Scroll` handler (pre-existing, unmodified) moves the + shared `Scroll` offset, and the next `LayoutCells()` pass reveals every + row, including the very last one of the 20-row / 400px worst case + against the DAT-authored 87px region. Retail's own LayoutDesc authors + NO scrollbar for this listbox either (`ScrollbarElementId == 0`, + verified against BOTH the committed fixture `tests/AcDream.App.Tests/ + UI/Layout/fixtures/examine_2100006B_100005F2.json` and a fresh + `tools/LayoutDump` read of the live installed DAT — no drift). A + scrollbar-less, wheel-scrollable list clipped to its authored region + until the user scrolls or resizes IS retail's actual, already-correctly- + ported mechanism — not a regression. +- `src/AcDream.App/UI/UiViewport.cs:52` draws NOTHING (not black) when its + `TextureSlot` is unassigned (`if (!Visible || !TextureSlot.IsAssigned) + return;`). The creaturePanel's own full-panel backdrop + (`0x10000141`, DID `0x06004CC2`, ZLevel 100 — the furthest-back layer, + spanning the panel's whole `300x365` rect) is what shows through + wherever nothing else paints over it. This is the most likely explanation + for BOTH the missing paperdoll AND the "black rectangle": if the + viewport's `TextureSlot` never gets assigned, this backdrop is what the + owner is actually seeing, and it is genuinely the SAME defect wearing + two descriptions, not two. +- `CreatureAppraisalPresentation.cs` and `LivePresentationComposition.cs` + (the entire render-time viewport pipeline: `TryGetVisibleTarget`'s + fourth gate `CurrentObjectId`, `TrySynchronize`'s live-entity/mesh + lookup, and the dispatcher/composition gate that constructs the + presenter at all) are byte-for-byte UNCHANGED across the whole + `974fe88a..87e98395` window (`git log -p` for both files is empty). + +**Conclusion:** the trigger is one of `TryGetVisibleTarget`'s +`CurrentObjectId` check or `TrySynchronize`'s `LiveEntityRuntime. +TryGetWorldEntity`/`MeshRefs.Count` check, in code nothing in Campaign AS +touches — meaning either a pre-existing, previously-latent condition this +gate round happened to trigger, or a live/timing condition a hermetic test +cannot reproduce (no live entity, no live wire exchange). + +**Probe added this session** (temporary — delete with the real fix): +`ACDREAM_PROBE_CREATURE_APPRAISAL_VIEWPORT=1` — +`CreatureAppraisalViewportDiagnostics` in `CreatureAppraisalPresentation.cs` +logs `[AS-GF1-PROBE] creature-appraisal viewport: ` on every REASON +TRANSITION (not every frame) from both `TryGetVisibleTarget` and +`RetailCreatureAppraisalCloneFactory.TrySynchronize`. Next step: relaunch +with the flag set, examine a player, and read which one of the five +possible reasons (`no ActiveView`, `windowFrame hidden`, `viewport hidden`, +`no CurrentObjectId`, `entity not found`, `no MeshRefs`) fires — that +pinpoints the real fix. + ## #442 — Flake: DirectionalShadowCasterFrameTests.WarmDenseChangedFrames_AllocateZeroAndReadNoSceneRecords fails intermittently under full parallel suite load **Status:** OPEN. diff --git a/docs/launch-options.md b/docs/launch-options.md index 5c1d0e3c..02f97a89 100644 --- a/docs/launch-options.md +++ b/docs/launch-options.md @@ -181,6 +181,7 @@ fall back to `ACDREAM_DAT_DIR`. | `ACDREAM_ORBIT_DISTANCE_METERS` | `=`, must be finite and `>0` | Diagnostic-only initial distance for the offline orbit camera, so deterministic renderer acceptance captures land inside a finite shadow reach. | Own doc comment: "used by deterministic renderer acceptance captures." Rejects non-finite/non-positive values silently (parses to `null`, camera default used). | `null` (unset) → normal camera default | `RuntimeOptions.InitialOrbitDistanceMeters` → `GameWindow.cs:1412` → offline orbit-camera composition | | `ACDREAM_ORBIT_PITCH_DEGREES` | `=`, clamped `[-89, 89]` | Diagnostic-only initial orbit camera elevation. | Values outside `[-89,89]` or non-finite are silently rejected (→ `null`, default kept) rather than clamped. | `null` | `RuntimeOptions.InitialOrbitPitchDegrees` → `GameWindow.cs:1414` | | `ACDREAM_ORBIT_YAW_DEGREES` | `=`, must be finite | Diagnostic-only initial orbit camera heading. | Non-finite values silently rejected (→ `null`). | `null` | `RuntimeOptions.InitialOrbitYawDegrees` → `GameWindow.cs:1413` | +| `ACDREAM_PROBE_CREATURE_APPRAISAL_VIEWPORT` | `=1` | #443 temporary probe for the examination-window player-paperdoll regression: logs `[AS-GF1-PROBE] TryGetVisibleTarget: ` and `[AS-GF1-PROBE] TrySynchronize: ` on every REASON TRANSITION (not every frame), pinpointing which of the two private-viewport gates rejects the target | print-only, state-change-gated so cost stays near zero even left on for a whole session | off | `CreatureAppraisalViewportDiagnostics.Enabled` (`CreatureAppraisalViewportDiagnostics.cs`), consumed by `RetailCreatureAppraisalFrameView.TryGetVisibleTarget` and `RetailCreatureAppraisalCloneFactory.TrySynchronize` in `CreatureAppraisalPresentation.cs` | | `ACDREAM_PROBE_REVEAL_RADIUS` | `==1` (unparsable or `<1` → override absent; floor is 1, not 0) | #280 A/B measurement probe: forces the OUTDOOR reveal gate to use this landblock radius instead of the derived streaming window (near radius clamped to it), so a route can be measured with the pre-#280 behavior (`=1`, old `OutdoorNeighborhoodRadius`) vs. current | **Changes what gets revealed, not just measured** — genuinely resizes the reveal/visible window used by the live reveal gate. CLAUDE.md: "Leave it unset for any measurement or gate run — with it set you are measuring a different window than production." `=0` is rejected by the parser specifically because it would hang the very A/B route it exists to measure (`RequiredRenderRadius==0` fails `invalid-readiness-shape`). Not a user setting, not in Settings/RuntimeOptions, not persisted. | unset (derivation in charge, no override) | `StreamingDiagnostics.RevealRadiusOverride` (`StreamingDiagnostics.cs:25-27,76-80`), applied by `StreamingDiagnostics.ApplyRevealRadiusOverride` | | `ACDREAM_PROBE_WORLD_FRAME` | `=1` | gates one `[world-frame] agree` line per projected conversion in `DatLiveEntityProjectionMaterializer`, recording the world-frame center both `LiveWorldOriginState` (App) and Runtime's physics-state owner used (issue #283, "measurement only; it never gates placement") | print-only | off | `PhysicsDiagnostics.ProbeWorldFrameEnabled` | | `ACDREAM_SKY_PHASE_SECONDS` | `=` (any finite value; negative accepted, taken mod 1 per axis) | Campaign V slice V7 instrument-determinism pin: freezes the sky's cloud-sheet UV scroll to a fixed elapsed-seconds value instead of wall-clock time, so two launches of a differential/offline gate agree about cloud position. | **Non-obvious dual effect**: this ONE var pins TWO independently-designed clocks that happen to share a name-adjacent purpose — the sky renderer's cloud scroll (`SkyRenderer.AnimationPhaseSecondsOverride`) AND, since Campaign VM slice VM6, the atmospheric post-process graph's foliage-wind clock (`_windClockSecondsOverride`). A gate that only knows about "sky clouds" and sets this to freeze them will *also* freeze foliage-wind evolution — deliberately snapped-to-target on the first advance per an A6 review fix, but still a second surface a naive reader wouldn't expect this var to touch. Distinct from `ACDREAM_DAY_GROUP`/`ACDREAM_WORLD_TIME`, which pin the OTHER sky clock (day group/sun angle) — retail's clouds drift independently of the calendar date by design. | `null` → wall-clock driven (every ordinary run) | `RuntimeOptions.SkyAnimationPhaseSeconds` → `SkyRenderer.cs:79,85` (cloud UV scroll) **and** `AtmosphericPostProcessGraph.cs:560,586,671` (foliage-wind clock) | diff --git a/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs b/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs index f27b37d3..96f351c7 100644 --- a/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs +++ b/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs @@ -134,18 +134,39 @@ internal sealed class RetailCreatureAppraisalFrameView : width = 0; height = 0; if (_controller.ActiveView is not ( - AppraisalView.Creature or AppraisalView.Character) - || !IsEffectivelyVisible(_windowFrame) - || !IsEffectivelyVisible(_viewport) - || _controller.CurrentObjectId == 0u) + AppraisalView.Creature or AppraisalView.Character)) { + CreatureAppraisalViewportDiagnostics.ReportGate( + $"no ActiveView (was {_controller.ActiveView})"); + return false; + } + if (!IsEffectivelyVisible(_windowFrame)) + { + CreatureAppraisalViewportDiagnostics.ReportGate("windowFrame hidden"); + return false; + } + if (!IsEffectivelyVisible(_viewport)) + { + CreatureAppraisalViewportDiagnostics.ReportGate("viewport hidden"); + return false; + } + if (_controller.CurrentObjectId == 0u) + { + CreatureAppraisalViewportDiagnostics.ReportGate("no CurrentObjectId"); return false; } serverGuid = _controller.CurrentObjectId; width = (int)_viewport.Width; height = (int)_viewport.Height; - return width > 0 && height > 0; + if (width <= 0 || height <= 0) + { + CreatureAppraisalViewportDiagnostics.ReportGate( + $"zero viewport extent ({width}x{height})"); + return false; + } + CreatureAppraisalViewportDiagnostics.ReportGate("open"); + return true; } public void SetTextureHandle(uint textureHandle) => @@ -203,11 +224,19 @@ internal sealed class RetailCreatureAppraisalCloneFactory : synchronizedClone = null; boundsMin = Vector3.Zero; boundsMax = Vector3.Zero; - if (!_entities.TryGet(serverGuid, out WorldEntity source) - || source.MeshRefs.Count == 0) + if (!_entities.TryGet(serverGuid, out WorldEntity source)) { + CreatureAppraisalViewportDiagnostics.ReportSync( + $"entity not found (guid 0x{serverGuid:X8})"); return false; } + if (source.MeshRefs.Count == 0) + { + CreatureAppraisalViewportDiagnostics.ReportSync( + $"no MeshRefs (guid 0x{serverGuid:X8})"); + return false; + } + CreatureAppraisalViewportDiagnostics.ReportSync("synchronized"); WorldEntity clone = currentClone is not null && currentClone.SourceGfxObjOrSetupId == source.SourceGfxObjOrSetupId diff --git a/src/AcDream.App/Rendering/CreatureAppraisalViewportDiagnostics.cs b/src/AcDream.App/Rendering/CreatureAppraisalViewportDiagnostics.cs new file mode 100644 index 00000000..31ff553e --- /dev/null +++ b/src/AcDream.App/Rendering/CreatureAppraisalViewportDiagnostics.cs @@ -0,0 +1,52 @@ +using System; + +namespace AcDream.App.Rendering; + +/// +/// AS-GF1 (2026-08-25): a temporary, state-change-gated probe for the +/// creature-examination paperdoll regression (#443 — the animated paperdoll +/// stopped rendering for player targets sometime in Campaign AS, but +/// exhaustive review of the AS2-AS5 diff plus a worst-case hermetic +/// regression test (AppraisalUiControllerTests. +/// CharacterResponse_WorstCaseExtrasCombination_DoesNotThrowAndViewportGateStaysOpen) +/// proved never +/// blocks ActiveView/CurrentObjectId/the viewport's ancestor-visibility +/// chain, even under the combined AS3+AS4 worst case). This means the actual +/// failing condition is one of +/// 's four +/// gates or 's +/// two — none of which the Campaign AS diff touches — and could not be +/// reproduced hermetically (it needs a live entity + a live examine +/// exchange). Enable with ACDREAM_PROBE_CREATURE_APPRAISAL_VIEWPORT=1; +/// logs only on a REASON transition (not every frame) to stay cheap enough +/// to leave on for a whole session. Delete this class and its call sites in +/// the commit that lands the real fix. +/// +internal static class CreatureAppraisalViewportDiagnostics +{ + public static bool Enabled { get; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_CREATURE_APPRAISAL_VIEWPORT") == "1"; + + // Two independent last-reason latches — TryGetVisibleTarget and + // TrySynchronize each run every frame and would otherwise "transition" + // against EACH OTHER's most recent line on every healthy frame, + // defeating the point of state-change-only logging. + private static string? _lastGateReason; + private static string? _lastSyncReason; + + public static void ReportGate(string reason) + { + if (!Enabled || reason == _lastGateReason) + return; + _lastGateReason = reason; + Console.WriteLine($"[AS-GF1-PROBE] TryGetVisibleTarget: {reason}"); + } + + public static void ReportSync(string reason) + { + if (!Enabled || reason == _lastSyncReason) + return; + _lastSyncReason = reason; + Console.WriteLine($"[AS-GF1-PROBE] TrySynchronize: {reason}"); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs index b496b206..dfad6dbb 100644 --- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs @@ -1153,6 +1153,134 @@ public sealed class AppraisalUiControllerTests ("Society:", "Celestial Hand"), ExtraRow(extra, 0)); } + // ── AS-GF1 diagnostic: DEFECT-1/DEFECT-2 root-cause probe ────────────── + // Combines EVERY AS3+AS4 extras-list addition on one response (armor + // levels, society, allegiance cascade, ratings, all seven configurable + // extras) through the REAL examination layout + REAL row templates — + // the worst-case content length none of the individual AS3/AS4 tests + // exercise together. Confirms (a) no exception anywhere in + // Apply/ApplyCreature/RebuildCreatureStats/BuildExtra for this + // combination, (b) the viewport's own visibility gate + // (RetailCreatureAppraisalFrameView.TryGetVisibleTarget's ActiveView/ + // CurrentObjectId/ancestor-visible conditions) is unaffected by extras- + // list length, and (c) measures the real overflow magnitude driving + // DEFECT 2. + [Fact] + public void CharacterResponse_WorstCaseExtrasCombination_DoesNotThrowAndViewportGateStaysOpen() + { + ImportedLayout layout = FixtureLoader.LoadExamination(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = ObjectId, + Name = "Worstcase", + Type = ItemType.Creature, + }); + using var interaction = NewInteraction(objects, []); + var templates = new CreatureAppraisalRowTemplateFactory( + FixtureLoader.LoadExaminationRowTemplateInfos(), + NoTexture, + defaultFont: null); + using AppraisalUiController controller = Bind( + layout, + objects, + interaction, + new CombatState(), + [], + [], + () => { }, + () => { }, + templates, + resolveCharacterTitle: titleId => titleId == 13u ? "War Mage" : null, + localFactionBits: () => 0x1)!; + + interaction.ExamineSelectedOrEnterMode(ObjectId); + var properties = new PropertyBundle(); + properties.Strings[5u] = "Template"; // Character-view marker + properties.Ints[0x105] = 13; // CharacterTitleId + properties.Ints[113] = 1; // Gender: male + properties.Ints[188] = 1; // HeritageGroup: Aluvian + properties.Ints[281] = 0x1; // Faction1Bits: Celestial Hand + properties.Ints[287] = 50; // Society rank + properties.Ints[30] = 5; // AllegianceRank >= 1 + properties.Ints[35] = 12; // AllegianceFollowers (unused once titles present) + properties.Strings[21u] = "Monarch Title"; + properties.Strings[35u] = "Patron Title"; // different from Monarch -> two rows + properties.Ints[0x133] = 10; // DamageRating + properties.Ints[0x134] = 10; // DamageResistRating + properties.Ints[0x15E] = 10; // DotResistRating + properties.Strings[10u] = "Fellows"; + properties.Strings[43u] = "1/1/2003"; // DateOfBirth + properties.Ints[125] = 100000; // Age (seconds in Dereth) + properties.Ints[181] = 7; // ChessRank + properties.Ints[192] = 3; // FishingSkill + properties.Ints[43u] = 2; // NumDeaths (int table, same numeric id as DateOfBirth string id) + properties.Ints[262] = 5; // NumCharacterTitles + var armorLevels = new AppraiseInfoParser.ArmorLevel( + Head: 100, Chest: 110, Abdomen: 120, + UpperArm: 130, LowerArm: 140, Hand: 150, + UpperLeg: 160, LowerLeg: 170, Foot: 180); + + bool applied = controller.Apply(Parsed( + properties, MinimalCreatureProfile(), armorLevels: armorLevels)); + + Assert.True(applied); + Assert.Equal(AppraisalView.Character, controller.ActiveView); + Assert.NotEqual(0u, controller.CurrentObjectId); + + // The viewport-visibility gate this test exists to protect: + // RetailCreatureAppraisalFrameView.TryGetVisibleTarget requires the + // creaturePanel (viewport's ancestor) to report Visible, exactly + // like SetActiveView's `_creaturePanel.Visible = view is Creature or + // Character` line sets it. + UiElement creaturePanel = layout.FindElement( + AppraisalUiController.CreaturePanelId)!; + UiElement viewportHost = layout.FindElement( + AppraisalUiController.CreatureViewportId)!; + Assert.True(creaturePanel.Visible); + for (UiElement? current = viewportHost; current is not null; current = current.Parent) + Assert.True(current.Visible, $"ancestor 0x{current.EventId:X8} is not Visible"); + + UiItemList extra = CreatureExtraList(layout); + int rowCount = extra.GetNumUIItems(); + UiElement extraHost = layout.FindElement( + AppraisalUiController.CreatureExtraListId)!; + float contentHeight = rowCount * 20f; // CreatureAppraisalLayeredList.NewList's CellHeight + Console.WriteLine( + $"[AS-GF1] worst-case extras: {rowCount} rows, " + + $"{contentHeight}px content vs extraHost authored " + + $"{extraHost.Height}px at default window size."); + + // DEFECT 2's measured overflow: AS3+AS4's combined worst case is + // dramatically taller than the DAT-authored 87px region at the + // window's minimum (310x400) size. + Assert.True(rowCount > 15, $"expected a long worst-case list, got {rowCount} rows"); + Assert.True(contentHeight > extraHost.Height * 2, + $"expected content ({contentHeight}px) to badly overflow the " + + $"authored host ({extraHost.Height}px)"); + + // AS-GF1 probe: does UiItemList's generic wheel-scroll handler + // (OnEvent's UiEventType.Scroll branch, gated on CellWidth > 0f, + // which NewList sets) actually reveal the rows below the fold, the + // same way it does for every other scrollable list in this + // controller? If so, the overflow is inert (retail's own DAT + // authors NO ScrollbarElementId for either 0x10000149 or 0x10000335 + // either — verified against both the committed fixture and a fresh + // `tools/LayoutDump 0x2100006B 0x10000140 --props` read of the live + // installed DAT) and NOT itself a code defect. + UiItemSlot lastRow = Assert.IsType( + extra.GetItem(rowCount - 1)); + Assert.False(lastRow.Visible, "expected the last row to start below the fold"); + extra.OnEvent(new UiEvent( + extra.EventId, extra, UiEventType.Scroll, Data0: -1000)); + Assert.True(extra.Scroll.ScrollY > 0, "expected the wheel event to move the scroll offset"); + // OnEvent only moves the shared Scroll's offset; cell.Visible/Top are + // only recomputed by LayoutCells(), which OnDraw calls every frame. + // Simulate the next paint (this hermetic test never renders one). + extra.LayoutCells(); + Assert.True(lastRow.Visible, "expected scrolling to reveal the last row"); + } + [Fact] public void CreatureResponse_NeverGainsArmorLevelTrioOrLegend() { From ddbd7e409680220651c241a678b9a848888e0f71 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 14:15:26 +0200 Subject: [PATCH 76/89] =?UTF-8?q?docs+fix(ui):=20Campaign=20AS=20CLOSED=20?= =?UTF-8?q?=E2=80=94=20connected=20gate=20PASSED;=20AS-GF1=20probes=20stri?= =?UTF-8?q?pped;=20#443=20narrowed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner ran the Campaign AS connected gate live and passed it. The two gate findings resolved in-round: the extras-list "black rectangle" is retail's own authored scroll-less clipped listbox (no scrollbar authored on 0x10000335, verified against the live DAT; wheel-scroll/resize reveal rows — AS-GF1 65f6f584 ruled it not a code defect), and the paperdoll symptom narrowed from "renders nothing" to an intermittent FIRST-OPEN DELAY: the probe round proved the private render layer healthy from the first frames (nonzero handle, 34 MeshRefs, sane bounds/camera) for both the examination clone and the inventory doll, with mesh residency/upload latency the leading suspect. #443 stays open with that narrowed shape. Per the probe-dies-with-its-investigation rule this strips CreatureAppraisalViewportDiagnostics, its call sites, and the launch-options row in one commit (recoverable via git show 65f6f584). App hermetic suite green (6,337/0). Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 24 +++++++-- docs/launch-options.md | 1 - ...026-08-25-assess-window-parity-campaign.md | 17 ++++-- .../CreatureAppraisalPresentation.cs | 29 +---------- .../CreatureAppraisalViewportDiagnostics.cs | 52 ------------------- 5 files changed, 33 insertions(+), 90 deletions(-) delete mode 100644 src/AcDream.App/Rendering/CreatureAppraisalViewportDiagnostics.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 78208f5f..fb3beb76 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,11 +24,27 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. -## #443 — Examination window: player-target paperdoll viewport renders nothing (AS-GF1) +## #443 — Examination/paperdoll private viewport: doll appears only after a delay on first open (was: "renders nothing") -**Status:** OPEN — probe added, root cause NOT isolated. -**Component:** examination window / creature-appraisal private viewport. -**Filed:** 2026-08-25, AS-GF1 gate-fix session. +**Status:** OPEN (narrowed at the gate — intermittent first-open DELAY, not +a hard break; NOT gate-blocking, owner passed the Campaign AS gate with it). +**Component:** private entity viewports (examination clone, inventory +paperdoll — shared `PrivateEntityViewportRenderer`). +**Filed:** 2026-08-25, AS-GF1 gate-fix session. **Narrowed same day at the +gate's probe round:** with `ACDREAM_PROBE_CREATURE_APPRAISAL_VIEWPORT=1` +(probe since DELETED per the probe-dies rule — recover it with +`git show 65f6f584` if this recurs) the live session showed the render +layer HEALTHY from the first probed frames — nonzero texture-table handle, +34 MeshRefs, sane bounds and camera eye, for both the examination clone +and the inventory paperdoll — while the owner initially saw an empty +(black) pane that later popped in ("I can see the paper doll now, after a +while"), and a subsequent fresh session showed it promptly. Leading +suspect: private-clone MESH residency/upload latency in the shared arena +(the pass records draws only for resident meshes; the probe cannot see +residency). The gate's OTHER symptom (the "black rectangle" band over the +extras rows at small window heights) is the authored scroll-less +clipped-list behavior AS-GF1 ruled retail-correct below, plus the clip +line moving with resize — owner-accepted at the gate. Owner report at the Campaign AS connected gate: the animated 3-D paperdoll in the examination window (LayoutDesc `0x2100006B` element `0x10000148`) diff --git a/docs/launch-options.md b/docs/launch-options.md index 02f97a89..5c1d0e3c 100644 --- a/docs/launch-options.md +++ b/docs/launch-options.md @@ -181,7 +181,6 @@ fall back to `ACDREAM_DAT_DIR`. | `ACDREAM_ORBIT_DISTANCE_METERS` | `=`, must be finite and `>0` | Diagnostic-only initial distance for the offline orbit camera, so deterministic renderer acceptance captures land inside a finite shadow reach. | Own doc comment: "used by deterministic renderer acceptance captures." Rejects non-finite/non-positive values silently (parses to `null`, camera default used). | `null` (unset) → normal camera default | `RuntimeOptions.InitialOrbitDistanceMeters` → `GameWindow.cs:1412` → offline orbit-camera composition | | `ACDREAM_ORBIT_PITCH_DEGREES` | `=`, clamped `[-89, 89]` | Diagnostic-only initial orbit camera elevation. | Values outside `[-89,89]` or non-finite are silently rejected (→ `null`, default kept) rather than clamped. | `null` | `RuntimeOptions.InitialOrbitPitchDegrees` → `GameWindow.cs:1414` | | `ACDREAM_ORBIT_YAW_DEGREES` | `=`, must be finite | Diagnostic-only initial orbit camera heading. | Non-finite values silently rejected (→ `null`). | `null` | `RuntimeOptions.InitialOrbitYawDegrees` → `GameWindow.cs:1413` | -| `ACDREAM_PROBE_CREATURE_APPRAISAL_VIEWPORT` | `=1` | #443 temporary probe for the examination-window player-paperdoll regression: logs `[AS-GF1-PROBE] TryGetVisibleTarget: ` and `[AS-GF1-PROBE] TrySynchronize: ` on every REASON TRANSITION (not every frame), pinpointing which of the two private-viewport gates rejects the target | print-only, state-change-gated so cost stays near zero even left on for a whole session | off | `CreatureAppraisalViewportDiagnostics.Enabled` (`CreatureAppraisalViewportDiagnostics.cs`), consumed by `RetailCreatureAppraisalFrameView.TryGetVisibleTarget` and `RetailCreatureAppraisalCloneFactory.TrySynchronize` in `CreatureAppraisalPresentation.cs` | | `ACDREAM_PROBE_REVEAL_RADIUS` | `==1` (unparsable or `<1` → override absent; floor is 1, not 0) | #280 A/B measurement probe: forces the OUTDOOR reveal gate to use this landblock radius instead of the derived streaming window (near radius clamped to it), so a route can be measured with the pre-#280 behavior (`=1`, old `OutdoorNeighborhoodRadius`) vs. current | **Changes what gets revealed, not just measured** — genuinely resizes the reveal/visible window used by the live reveal gate. CLAUDE.md: "Leave it unset for any measurement or gate run — with it set you are measuring a different window than production." `=0` is rejected by the parser specifically because it would hang the very A/B route it exists to measure (`RequiredRenderRadius==0` fails `invalid-readiness-shape`). Not a user setting, not in Settings/RuntimeOptions, not persisted. | unset (derivation in charge, no override) | `StreamingDiagnostics.RevealRadiusOverride` (`StreamingDiagnostics.cs:25-27,76-80`), applied by `StreamingDiagnostics.ApplyRevealRadiusOverride` | | `ACDREAM_PROBE_WORLD_FRAME` | `=1` | gates one `[world-frame] agree` line per projected conversion in `DatLiveEntityProjectionMaterializer`, recording the world-frame center both `LiveWorldOriginState` (App) and Runtime's physics-state owner used (issue #283, "measurement only; it never gates placement") | print-only | off | `PhysicsDiagnostics.ProbeWorldFrameEnabled` | | `ACDREAM_SKY_PHASE_SECONDS` | `=` (any finite value; negative accepted, taken mod 1 per axis) | Campaign V slice V7 instrument-determinism pin: freezes the sky's cloud-sheet UV scroll to a fixed elapsed-seconds value instead of wall-clock time, so two launches of a differential/offline gate agree about cloud position. | **Non-obvious dual effect**: this ONE var pins TWO independently-designed clocks that happen to share a name-adjacent purpose — the sky renderer's cloud scroll (`SkyRenderer.AnimationPhaseSecondsOverride`) AND, since Campaign VM slice VM6, the atmospheric post-process graph's foliage-wind clock (`_windClockSecondsOverride`). A gate that only knows about "sky clouds" and sets this to freeze them will *also* freeze foliage-wind evolution — deliberately snapped-to-target on the first advance per an A6 review fix, but still a second surface a naive reader wouldn't expect this var to touch. Distinct from `ACDREAM_DAY_GROUP`/`ACDREAM_WORLD_TIME`, which pin the OTHER sky clock (day group/sun angle) — retail's clouds drift independently of the calendar date by design. | `null` → wall-clock driven (every ordinary run) | `RuntimeOptions.SkyAnimationPhaseSeconds` → `SkyRenderer.cs:79,85` (cloud UV scroll) **and** `AtmosphericPostProcessGraph.cs:560,586,671` (foliage-wind clock) | diff --git a/docs/plans/2026-08-25-assess-window-parity-campaign.md b/docs/plans/2026-08-25-assess-window-parity-campaign.md index 8818d348..9ee79da7 100644 --- a/docs/plans/2026-08-25-assess-window-parity-campaign.md +++ b/docs/plans/2026-08-25-assess-window-parity-campaign.md @@ -1,9 +1,16 @@ # Campaign AS — assess/examination window retail parity (player targets) -**Status: IMPLEMENTATION COMPLETE 2026-08-25 — AS1–AS5 REVIEW-CLOSED, AS6 -gate script written (`docs/research/2026-08-25-campaign-as-test-script.md`). -ONLY the owner-driven connected gate remains. Branch NOT pushed — the owner -pushes on their word.** +**Status: CLOSED — CONNECTED GATE PASSED 2026-08-25 ("fixed! gate pass!").** +AS1–AS5 review-closed; the gate round harvested two findings, both resolved +in-round: the extras-list "black rectangle" is retail's own authored +scroll-less clipped listbox (no scrollbar authored on 0x10000335 — verified +against the live DAT; wheel-scroll and resize reveal rows; AS-GF1 +`65f6f584` ruled it not-a-code-defect), and the paperdoll's absence +narrowed to an intermittent FIRST-OPEN DELAY (#443, kept open) after the +probe round proved the render layer healthy — the render pipeline was +never broken by this campaign. Gate probes deleted at close per the +probe-dies rule (recoverable via `git show 65f6f584`). Branch NOT pushed — +the owner pushes on their word. Owner report (2026-08-25, side-by-side screenshots, acdream vs retail, both assessing the player "Dww"): acdream's examination window on a PLAYER target @@ -147,4 +154,4 @@ round → narrow re-review → REVIEW-CLOSED. | AS3 | **REVIEW-CLOSED 2026-08-25** | `1616cd3d` (no fix round) | APPROVE first pass — trio/legend/monster-path exact by offset-level decomp verification; R3 flattening theory disproven at source; ratings adjudication: pre-AS3 code already retail-exact. 5 NITs: 12 (legend-order comment), 11 (stronger refresh test) fold into AS4; 14/15 done in the close commit; 13 (geometry-keyed test helper) noted | | AS4 | **REVIEW-CLOSED 2026-08-25** | `4ade9b04` / `bf8f5b70` (docs-only fix) | port exact per dual-lens review (presence-gate adjudicated FOR the implementer at `InqInt @0x005B3830`; Time-in-Dereth = pre-existing `RetailDurationText @0x00565E10` port, correct reuse); fix round was oracle-doc corrections + records only, NO code change. True full-solution hermetic count 15,528 (the commit's 15,410 was a mis-report). **AS6 carry-note: the Society green/red colorIdx is MODEL-ONLY (ResolveColor no-op pending AP-110 FontInfo residual) — the gate script must NOT gate on row colors.** Pre-existing parallel-load flake surfaced (shadow-caster zero-alloc pin) — #442, unrelated to AS4 | | AS5 | **REVIEW-CLOSED 2026-08-25** | `8f8c0c3a` / `9f3e3263` | the campaign's most rigorously verified slice: 170/170 title strings confirmed (164 mechanical diff, 6 PE byte-decoded from the PDB-paired binary), all 20 dispatch arms, all 17 bounds tests, both call sites re-derived; zero behavioral findings. Fix round = "retires AP-109" → "narrows" at 5 comment sites + the plan (the FormatXp sliver keeps the row active); re-review also flagged + this close fixed the last "retires" phrasing (`AppraisalUiController.cs`) and the CT plan's stale retirement intent | -| AS6 | **DONE 2026-08-25 — script committed, WAITING ON THE OWNER'S DRIVE** | (this commit) | `docs/research/2026-08-25-campaign-as-test-script.md`; carries the two standing rulings (row colors model-only — do not gate; AD-114 paperdoll deviation expected) and the R3 legend retail side-by-side check | +| AS6 | **GATE PASSED 2026-08-25** | script `87e98395`; gate round `65f6f584` (AS-GF1) + probe-removal close commit | owner ran the gate live; two findings harvested and resolved in-round (extras clip = retail's authored scroll-less listbox, not a defect; paperdoll = #443 first-open delay, render layer proven healthy by probe); identity block, AL rows, allegiance/extras, and the rank-title title bar all owner-verified; probes stripped at close | diff --git a/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs b/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs index 96f351c7..9b34a6ed 100644 --- a/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs +++ b/src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs @@ -136,37 +136,19 @@ internal sealed class RetailCreatureAppraisalFrameView : if (_controller.ActiveView is not ( AppraisalView.Creature or AppraisalView.Character)) { - CreatureAppraisalViewportDiagnostics.ReportGate( - $"no ActiveView (was {_controller.ActiveView})"); return false; } if (!IsEffectivelyVisible(_windowFrame)) - { - CreatureAppraisalViewportDiagnostics.ReportGate("windowFrame hidden"); return false; - } if (!IsEffectivelyVisible(_viewport)) - { - CreatureAppraisalViewportDiagnostics.ReportGate("viewport hidden"); return false; - } if (_controller.CurrentObjectId == 0u) - { - CreatureAppraisalViewportDiagnostics.ReportGate("no CurrentObjectId"); return false; - } serverGuid = _controller.CurrentObjectId; width = (int)_viewport.Width; height = (int)_viewport.Height; - if (width <= 0 || height <= 0) - { - CreatureAppraisalViewportDiagnostics.ReportGate( - $"zero viewport extent ({width}x{height})"); - return false; - } - CreatureAppraisalViewportDiagnostics.ReportGate("open"); - return true; + return width > 0 && height > 0; } public void SetTextureHandle(uint textureHandle) => @@ -225,18 +207,9 @@ internal sealed class RetailCreatureAppraisalCloneFactory : boundsMin = Vector3.Zero; boundsMax = Vector3.Zero; if (!_entities.TryGet(serverGuid, out WorldEntity source)) - { - CreatureAppraisalViewportDiagnostics.ReportSync( - $"entity not found (guid 0x{serverGuid:X8})"); return false; - } if (source.MeshRefs.Count == 0) - { - CreatureAppraisalViewportDiagnostics.ReportSync( - $"no MeshRefs (guid 0x{serverGuid:X8})"); return false; - } - CreatureAppraisalViewportDiagnostics.ReportSync("synchronized"); WorldEntity clone = currentClone is not null && currentClone.SourceGfxObjOrSetupId == source.SourceGfxObjOrSetupId diff --git a/src/AcDream.App/Rendering/CreatureAppraisalViewportDiagnostics.cs b/src/AcDream.App/Rendering/CreatureAppraisalViewportDiagnostics.cs deleted file mode 100644 index 31ff553e..00000000 --- a/src/AcDream.App/Rendering/CreatureAppraisalViewportDiagnostics.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; - -namespace AcDream.App.Rendering; - -/// -/// AS-GF1 (2026-08-25): a temporary, state-change-gated probe for the -/// creature-examination paperdoll regression (#443 — the animated paperdoll -/// stopped rendering for player targets sometime in Campaign AS, but -/// exhaustive review of the AS2-AS5 diff plus a worst-case hermetic -/// regression test (AppraisalUiControllerTests. -/// CharacterResponse_WorstCaseExtrasCombination_DoesNotThrowAndViewportGateStaysOpen) -/// proved never -/// blocks ActiveView/CurrentObjectId/the viewport's ancestor-visibility -/// chain, even under the combined AS3+AS4 worst case). This means the actual -/// failing condition is one of -/// 's four -/// gates or 's -/// two — none of which the Campaign AS diff touches — and could not be -/// reproduced hermetically (it needs a live entity + a live examine -/// exchange). Enable with ACDREAM_PROBE_CREATURE_APPRAISAL_VIEWPORT=1; -/// logs only on a REASON transition (not every frame) to stay cheap enough -/// to leave on for a whole session. Delete this class and its call sites in -/// the commit that lands the real fix. -/// -internal static class CreatureAppraisalViewportDiagnostics -{ - public static bool Enabled { get; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_CREATURE_APPRAISAL_VIEWPORT") == "1"; - - // Two independent last-reason latches — TryGetVisibleTarget and - // TrySynchronize each run every frame and would otherwise "transition" - // against EACH OTHER's most recent line on every healthy frame, - // defeating the point of state-change-only logging. - private static string? _lastGateReason; - private static string? _lastSyncReason; - - public static void ReportGate(string reason) - { - if (!Enabled || reason == _lastGateReason) - return; - _lastGateReason = reason; - Console.WriteLine($"[AS-GF1-PROBE] TryGetVisibleTarget: {reason}"); - } - - public static void ReportSync(string reason) - { - if (!Enabled || reason == _lastSyncReason) - return; - _lastSyncReason = reason; - Console.WriteLine($"[AS-GF1-PROBE] TrySynchronize: {reason}"); - } -} From 82e4b4cb6d982754aeb068e40bcdee00591e9c1f Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 17:39:44 +0200 Subject: [PATCH 77/89] fix(render): harden portal exit handoff --- AGENTS.md | 323 +++++++++++++----- docs/ISSUES.md | 80 ++++- docs/launch-options.md | 3 +- .../Composition/SessionPlayerComposition.cs | 20 +- .../Rendering/PortalTunnelPresentation.cs | 18 + .../RenderFrameDiagnosticsController.cs | 7 +- .../Rendering/TeleportViewPlaneController.cs | 28 +- .../LocalPlayerTeleportController.cs | 43 ++- .../Streaming/RevealTimingProbe.cs | 108 +++++- .../Streaming/StreamingDiagnostics.cs | 36 ++ .../Streaming/WorldRevealCoordinator.cs | 10 +- .../World/TeleportAnimSequencer.cs | 27 +- .../Rendering/PortalTunnelAssetTests.cs | 60 +++- .../LocalPlayerTeleportControllerTests.cs | 21 +- .../Streaming/RevealTimingProbeTests.cs | 129 +++++++ .../Streaming/StreamingDiagnosticsTests.cs | 23 ++ .../World/TeleportAnimSequencerTests.cs | 79 ++++- 17 files changed, 896 insertions(+), 119 deletions(-) create mode 100644 tests/AcDream.App.Tests/Streaming/RevealTimingProbeTests.cs create mode 100644 tests/AcDream.App.Tests/Streaming/StreamingDiagnosticsTests.cs diff --git a/AGENTS.md b/AGENTS.md index dd7f5549..5ac7376d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,8 +132,194 @@ user-accepted, including exact response flags, independent examination window, inscription transaction, complete creature/item/spell reports, favorite-spell press/right-click behavior, modern scarab/prismatic formula, DAT component icons, foreground stacking, and authored 310 x 400 extent. -Resume at Slice 4 equipped-child world picking, then vendor browse and -authoritative transactions. +Slice 4 equipped-child world picking passed its two-client Coldeve gate and +was user-accepted 2026-07-29. **Slices 5 and 6 (the complete vendor +experience — browse, staged buying, selling, walk-to-use, the authored +panel) closed user-accepted 2026-08-08; the six-slice program is COMPLETE +(see the plan's PROGRAM CLOSEOUT). The vendor arc also exposed and fixed +two latent client-wide crashers (#348 cursor-handle exhaustion, #350 +render-ledger overflow).** **Campaign P — physics retail-feel parity +(`docs/plans/2026-07-29-physics-parity-campaign.md`) is CLOSED 2026-07-31 +— final user matrix accepted.** Every physics-scope gap from the +2026-07-29 audit landed and user-gated: #266 run speed (retail's ==800 +sentinel — ACE's >=800 is a misread; never re-import), the #265/#166 +landing-momentum + bounce family +(`docs/research/2026-07-30-landing-bounce-family.md`), the #267 vitae +panel, #268 (panel colors + augmentation bonuses), #269 (slope-stop slide +— the live-trace contact-plane-restore fix), and TS-8 (0x02C2 StatMod +parse). See the plan doc for the retired-row ledger. **Campaign A — audio +retail parity (`docs/plans/2026-08-08-audio-parity-campaign.md`) is +CODE-COMPLETE 2026-08-08** with slices A1–A6 landed and listening-gate +rounds user-driven; open tail: #358 (Ctrl+M mute chord never fires) and +the formal plan-status flip. **Campaign CH — chat & interface-text retail +parity (`docs/plans/2026-08-09-chat-parity-campaign.md`) is CLOSED +USER-ACCEPTED 2026-08-10** after five connected gate rounds: retail +colors, the SpewBox with retail's two-plane glyph outlines, working side +channels, the 152-verb command registry, the CH6 window shell (floating +windows, all-corner resize, opacity), and verbatim /help. Carried tail: +#360/#361, #366, #369, AP-177/190/191, and the round-5 review S1–S3 +polish items. **Campaign OP — the retail four-tab Options panel +(`docs/plans/2026-08-10-options-panel-campaign.md`) is CODE-COMPLETE +2026-08-11.** Retail's Options panel (Gameplay Options / Character / Chat / +Config, LayoutDesc `0x2100002B`) plus the Configure Keyboard screen are +acdream's ONE in-client settings surface (design D1): F11/toolbar open the +authored tab host; `RuntimeCharacterOptionsState` + the 53-id +`CharacterOptionTable` own option storage; retail's wire split ships exactly +(21 auto-save ids → `0x0005` immediate, the rest ride the real `0x01A1` +PlayerModule blob with Apply/logout/480 s flushes, header always `0x460`); +headless bots declare options by name (OP7's live bot-vs-ACE gate PASSED); +OP9 retired the dead F11 `SettingsPanel`/`SettingsVM` surface and the +`GameplaySettings` record outright. OP1/OP2/OP7/OP9 CLOSED through dual/ +combined Opus review. **2026-08-14 re-gate round:** the whole gate-4 fix +batch (#372 both halves, #374, #375, #378–#382, #385) is USER-PASSED; the +OP8 first look filed + same-day-fixed #394/#395/#396 (authored 18px-serif +row-caption font; the retail `GetNameFromKey` key-name pipeline — DAT +tables `0x2300000A`/`0x2300000B`/`0x23000007` via GetDIDByEnum category 4, +OS-localized fallback, register AD-96; the `InitiateBinding` capture- +instruction WAIT dialog) plus the WaitDialog-type-0x19 crash (`2a81e813`, +live-verified no-crash). **STILL OWED: the full §OP3–§OP6 script sections +and §OP8's visual re-check** — script +`docs/research/2026-08-11-campaign-op-test-script.md`, launch with +`ACDREAM_RETAIL_UI=1`. Tail: +#371, #373, AP-198/199/201/202/203. START at +`claude-memory/project_settings_options_digest.md`. + +**Campaign FA — the retail social panel (Fellowship & Allegiance) +(`docs/plans/2026-08-11-fellowship-allegiance-campaign.md`) is +CODE-COMPLETE 2026-08-12.** Retail authors ONE four-tab `gmPanelUI` social +panel (Friends / Allegiance / Fellowship / Squelch, host slot +`0x1000018F`, id 12; F3 = Allegiance, F4 = Fellowship, keyboard-only — +Allegiance is the authored DEFAULT tab), mounted with the OP3 Options-panel +recipe. The Fellowship and Allegiance pages are LIVE end-to-end: real wire +(FA1 repaired the never-called H.2 builders + parsers — retail's FOUR +tree-rejection rules, ELEVEN version gates, the byte-decoded `>=9` size and +the truncated XP-share table), two session-scoped Runtime owners +(`RuntimeFellowshipState`/`RuntimeAllegianceState`, both clear at +generation reset — D2 corrected), and the authored panels through +`LayoutImporter`. Friends/Squelch bind read-only to J4.1's owners. +**The fellowship two-session flow is PROVEN over the live wire** — FA6's +automated bot-vs-ACE gate (`testaccount`/`+Acdream` + `testaccount2`/ +`+Horan`) passed: the recruited bot's OWN `RuntimeFellowshipState` flips +`IsInFellowship`. Six FA slices, each dual-lens Opus reviewed → fix round → +narrow re-review; the reviews caught what tests can't (retail's 4th tree +rule, the D2 reset-lifetime inversion, the D6 server-side invite filter, +a seam-map entry that would have re-introduced a fixed bug). OWED: the +user's connected gates (§FA3-§FA6 of +`docs/research/2026-08-12-campaign-fa-test-script.md`, several +`[TWO-CLIENT]`), and **#384** — the allegiance-swear bot gate is +deferred/disabled because ACE returns NOTHING to the `0x001D` swear at +0.005 m (no confirmation, no tree update, no error; needs ACE-console +disambiguation — the swear CODE is done+reviewed, only its automated +two-session proof is unverified; register AD-87). Tail: #383 (installed- +DAT vs committed-fixture drift, found at FA3). START at +`claude-memory/project_fellowship_allegiance_campaign.md`. + +**2026-08-13/14 gate block — SOCIAL GATES + SECURE TRADE all +USER-PASSED.** The social panel's connected gate rounds closed (border-only +move cursor, amber row selection, wrapped empty-state text, composed +confirmation sentences via the new `DatStringResolver.ResolveTemplate` +StringTable-interleave port, the refused-drop SpewBox notice via the +`InventoryTransactionState.RequestFailed` seam, live friends +Online/Offline through the authored row state machine + the new UiText +per-state string swap). Same block: powerbar mode captions +(jump 'Height' right-aligned per-STATE justify / 'Power'↔'Accuracy' by +combat mode), release-edge airborne jump refusal (supersedes CH round-1's +press-edge report), and **SECURE TRADE SHIPPED + two-client user gate +PASSED 2026-08-14** — gmSecureTradeUI window (LayoutDesc `0x2100000D`), +full `0x1F6`–`0x208` wire, `RuntimeTradeState` as the third sibling +J-owner, both retail open paths, staged-item trading marker +(`ClientObject.TradeState` now live), cancel text. START at +`claude-memory/project_secure_trade.md`; the deferred-Func lesson is +`claude-memory/feedback_resolve_deferred_funcs_per_call.md`. Register: +AD-93/AD-94 filed, AD-85 narrowed, AD-81 amended, AD-89/AD-95 retired. +Filed: #393 (texture-detail options, post-M4). + +**Campaign LA — the alpha launcher (ACTIVE 2026-08-14):** Avalonia +launcher/installer/updater (Windows+Linux) + the retail character- +management screen, driven autonomously under a user-set goal: Fable +plans, Sonnet implements, Opus dual-lens reviews (architectural + +retail-faithful). Spec: +`docs/superpowers/specs/2026-08-14-launcher-campaign-design.md`; plan + +ledger: `docs/plans/2026-08-14-launcher-campaign.md`; START at +`claude-memory/project_launcher_direction.md`. Key recon corrections +already binding: retail's select screen (`gmCharacterManagementUI`) has +NO 3D preview (chargen-only machinery); UI Studio no longer exists +(deleted at Campaign V — ignore stale memory/docs claims otherwise); +App `Program.cs` has no subcommand dispatch (the `--session-config` flag +is additive). +LA0 through LA11's automated scope are review-closed. The launcher composer is now +compiled into both host test suites, and Launcher.Core runs in the portable +Windows/Ubuntu CI closure. The self-contained Avalonia launcher, +transactional two-host plugin lifetime, shared login-command route, +Runtime-owned retail selection state, authored DAT character screen, and +crash-safe verified installer plus atomic cross-platform updater/self-updater +are integrated. Windows group-isolated Headless stop, isolated update fixtures, +strict status/redaction evidence, and the exact Windows/Ubuntu operator script +are landed; the integrated preflight passes 32/32 commands and 14,012 tests / +5 skips. Only the connected/visual/real-DAT user gate remains before shipment. + +**Campaign CC — retail character creation (CLOSED USER-ACCEPTED +2026-08-16).** All seven slices REVIEW-CLOSED; the connected gate ran as +one extended round (findings GF-1..16 + re-tests R2/R3/R4, fix batches +A-G + closeout + two re-test rounds, final build `1.0.2-cc.o`) and +PASSED. **Milestone: the first live character ever created by acdream +against ACE landed mid-round.** The gate round's own harvest hardened +shared surfaces well beyond chargen: authored text margins (P0x23-26), +the authored Unselected/Selected state pair + per-state label color, +un-consumed Type-12 media children (frames/scrollbars client-wide), +single-sprite scrollbar thumbs, UiButton/UiDatElement Tint, the +dialog-always-on-top re-raise (the invisible-modal input blackhole), a +truthful client crash self-report + bounded stderr capture (#405-#407 +fixed, #406 fixed; #408/#409/#410 filed for their own rounds). The full retail creation flow: Create +button (retail's exact `UpdateButtons` roster` — live capture of every player-side - `PhysicsEngine.ResolveWithTransition` call. Each call appends one - JSON Lines record with full inputs, PhysicsBody snapshot before AND - after, plus the `ResolveResult`. Filtered to `IsPlayer` mover flag - — NPC / remote DR calls don't pollute. Pairs with the trajectory - replay harness comparison tests to diff captured vs harness state - per field — the first divergence pinpoints missing apparatus state. - Capture is OFF when the env var is unset (one null-check cost per - call). -- `ACDREAM_DUMP_CELLS=` / `ACDREAM_DUMP_GFXOBJS=` — dump - resolved cell/GfxObj polygon tables as JSON when ids cache. Used - for harness fixture extraction. +Every environment variable and command-line argument the client reads — +what it does, its exact value shape, and **what else it changes about the +run** — is documented in +[`docs/launch-options.md`](docs/launch-options.md). That file is the single +source of truth for every probe we have and how to turn one on, and it is +enforced by `LaunchOptionsDocumentationTests`: a flag without a documented +row fails the build, and so does a documented row whose read site was +deleted. **Any future probe that stays in the code gets its row there in +the same commit — no exceptions.** + +The binding rules: + +- **Every probe and dump is OFF by default.** Nothing that prints, records, + or costs performance may activate without its env var explicitly set + (`=1`). The only default-on flags are retail *behaviors* wearing an + A/B off-switch (`ACDREAM_RETAIL_CHASE`, `ACDREAM_CAMERA_COLLIDE`, + `ACDREAM_CAMERA_ALIGN_SLOPE`, `ACDREAM_RETAIL_CLOSE_DEGRADES` — `=0` + disables); that set is frozen by `LaunchOptionsDocumentationTests` — + never add a default-on diagnostic. +- **Read the side-effects column before any measurement.** Flags that look + inert are not: `ACDREAM_AUTOMATION_ARTIFACT_DIR` also builds a per-frame + diagnostics referee (#432), and `ACDREAM_STREAM_RADIUS` measures a + streaming window production never uses. +- **A temporary probe dies with its investigation.** Add the row when you + add the probe; delete both in the commit that fixes the issue. ### Outbound motion wire format (acdream → ACE) @@ -1478,8 +1645,8 @@ already-running ACE session via the handshake race. ## Reference repos: cross-check the relevant ones -The `references/` tree holds **six** vendored projects (ACE, ACViewer, -WorldBuilder, Chorizite.ACProtocol, holtburger, AC2D). They overlap in +The `references/` tree holds **five** vendored projects (ACE, ACViewer, +WorldBuilder, Chorizite.ACProtocol, holtburger). They overlap in some areas and disagree in others. Before committing to an approach, **cross-reference at least two of them** for the domain you're working in — the per-domain hierarchy in the next section tells you which to @@ -1488,7 +1655,7 @@ the relevant references is almost always the truth. The user has repeatedly had to remind me about this when I narrowly searched one ref and missed obvious answers in another. -The six references: +The five references: - **`references/ACE/`** — ACEmulator server. Authority on the wire protocol (packet framing, ISAAC, game message opcodes, serialization @@ -1538,15 +1705,15 @@ The six references: the message-builder layer. ACE shows what the server expects; holtburger shows what a real client actually sends. -- **`references/AC2D/`** — **C++ AC client emulator.** Oldest reference, - fixed-function OpenGL, but has the **real AC terrain split formula** - (`FSplitNESW` with constants `0x0CCAC033`, `0x421BE3BD`, `0x6C1AC587`, - `0x519B8F25`) which differs from WorldBuilder's physics-path formula. - Also has the complete `0xF61C` movement packet format with flag bits - and the `stMoveInfo` sequence counters. Key lesson from AC2D: it does - NOT do client-side terrain Z — it sends movement keys to the server - and uses the server's authoritative Z. See - `docs/research/2026-04-12-movement-deep-dive.md` for the full analysis. +**AC2D is a retired reference (2026-07-29).** It was a C++ AC client demo +and the sixth entry in this list; it is no longer vendored under +`references/` and must not be re-cloned. Everything we took from it is +already written down and still stands: the terrain split formula +`FSplitNESW` (constants `0x0CCAC033`, `0x421BE3BD`, `0x6C1AC587`, +`0x519B8F25`), the `0xF61C` movement packet layout, and the finding that a +client need not compute terrain Z itself. The historical analysis lives in +`docs/research/2026-04-12-movement-deep-dive.md`; the UI dat-id work it fed +is in `docs/research/retail-ui/`. Cite those, not the repo. ### Reference hierarchy by domain @@ -1571,9 +1738,9 @@ decompiled client code and would have fixed it in minutes. | **EnvCell / dungeon rendering** (cell geometry, portal visibility, collision mesh) | **WorldBuilder `EnvCellRenderManager.cs` + `PortalRenderManager.cs`** | ACME `EnvCellManager.cs` (more complete for collision); ACViewer `Physics/Common/EnvCell.cs` | WB is acdream's geometry base; ACME for collision until ported. | | **Particles / sky** (particle systems, weather, sky particles) | **WorldBuilder `SkyboxRenderManager.cs` + `ParticleEmitterRenderer.cs` + `ParticleBatcher.cs`** | retail decomp | WB is acdream's particle base. | | **Visibility / culling** (frustum, cell visibility) | **WorldBuilder `VisibilityManager.cs` + `Frustum.cs`** | — | WB. | -| **Network protocol** (wire format, packet framing, fragment assembly, ISAAC) | **holtburger** `crates/holtburger-session/` | AC2D `cNetwork.cpp` (simpler, good for cross-check) | ACE shows the server side; holtburger + AC2D show the client side. | -| **Client behavior** (what to send when, login flow, ack pattern, keepalive) | **holtburger** `crates/holtburger-core/src/client/` | AC2D `cNetwork.cpp` + `cInterface.cpp` | holtburger is the most complete; AC2D is simpler but confirmed working. | -| **Movement** (MoveToState format, AutonomousPosition, sequence counters, speed) | **holtburger** `client/movement/` | AC2D `cNetwork.cpp:2592-2664` (0xF61C format) | See `docs/research/2026-04-12-movement-deep-dive.md` for the full cross-reference. | +| **Network protocol** (wire format, packet framing, fragment assembly, ISAAC) | **holtburger** `crates/holtburger-session/` | `docs/research/named-retail/` | ACE shows the server side; holtburger shows the client side. AC2D was the second client-side cross-check here — retired reference; historical analysis remains in `docs/research/2026-04-12-movement-deep-dive.md`. | +| **Client behavior** (what to send when, login flow, ack pattern, keepalive) | **holtburger** `crates/holtburger-core/src/client/` | `docs/research/named-retail/` | holtburger is the most complete. AC2D was the simpler confirmed-working cross-check — retired reference; historical analysis remains in `docs/research/2026-04-12-movement-deep-dive.md`. | +| **Movement** (MoveToState format, AutonomousPosition, sequence counters, speed) | **holtburger** `client/movement/` | `docs/research/named-retail/` | AC2D `cNetwork.cpp:2592-2664` was the `0xF61C` format secondary — retired reference; historical analysis remains in `docs/research/2026-04-12-movement-deep-dive.md`, which carries the packet layout and the terrain-split formula verbatim. | | **Server expectations** (what ACE accepts/rejects, validation thresholds) | **ACE** `Source/ACE.Server/Network/` | — | Only ACE knows what the server actually validates. | | **Silk.NET / .NET 10 idioms** (GL calls, shader setup, VAO patterns) | **WorldBuilder original** | ACME (same stack) | Both use the same backend; original has cleaner isolated examples. | | **Protocol field order** (packed dwords, type prefixes, flag enums) | **Chorizite.ACProtocol** `Types/*.cs` | holtburger (cross-check) | Generated from protocol XML; has accurate field comments. | diff --git a/docs/ISSUES.md b/docs/ISSUES.md index fb3beb76..24cec938 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -1443,7 +1443,9 @@ died there. Distinct from the LU5 UX work. ## #419 — Portal-tunnel rim polygon visible (FOV-coupled) + ring flash at exit (camera dolly vs retail's view-plane animation) -**Status:** OPEN (filed 2026-08-17, user screenshot + FOV experiment). +**Status:** ✅ FIXED / OWNER-ACCEPTED 2026-08-25 — the centered disk, 16:9 +faceted outer rim, and destination lower-viewport hole are gone in the live +owner-pak gate. **Symptom:** the tunnel's low-poly mouth shows as a faceted polygon silhouette against black, scaling with the Config FOV slider (barely visible at minimum FOV); a brief ring flash remains at exit even at @@ -1541,6 +1543,69 @@ interior there ⇒ our pipeline, rings there ⇒ shared dat interpretation); (4) a retail side-by-side screenshot for ground truth (brightness included). Fix only against that evidence. +**2026-08-25 apparatus closeout:** step (1) is implemented as +`ACDREAM_PROBE_TUNNEL_FREEZE=1` (authored frame 72) or `=N` (frame 2–120). +The diagnostic withholds the TAS_TUNNEL transition even after world-ready, +then stops the tunnel sequence and roll at the requested frame and emits one +`[tunnel-freeze] ... state=held` marker. It intentionally prevents placement, +reveal, and login completion until cancellation/process exit; it is a capture +tool, not a shipping behavior or a performance mode. Step (2)'s frozen +RenderDoc capture (`portal-frozen-frame1178.rdc`) confirmed the authored finite +mesh and production portal pass were actually drawing; it did not support a +cull/material/asset substitution. Step (3) was attempted, but ACViewer was not +a trustworthy renderer oracle for this path and was discarded rather than used +as fix evidence. Step (4)'s direct retail capture showed the radial tunnel +field swapping straight to the destination world, with no small centered disk. + +**Root cause and final fix (2026-08-25):** the exit defect was a two-part +presentation-boundary error, not tunnel content. First, our outgoing viewport +remained eligible through retail animation-table level 1022 even though paired +initial 40 fps captures appeared to establish level 1013 as the last sample +whose radial field covered the measured center rings; level 1016 and later +exposed a small finite-mesh disk. `TeleportAnimSequencer` initially retired the +outgoing viewport after quantized level 1013. Second, +`LocalPlayerTeleportPresentation.Tick` +published the `WorldFadeIn` terminal projection while the tunnel could still be +visible, and both teleport/login event handlers called the synchronous +destination-release suffix before `ExitTunnel`. A slow suffix could therefore +hold the invalid tunnel/projection combination on screen. The presentation now +hides portal space before publishing any snapshot whose `ShowTunnel` is false, +and both handlers enforce retail's order: hide portal, reveal/release world, +then play `Sound_UI_ExitPortal`. The later `ExitTunnel` call remains idempotent. + +**Acceptance evidence:** the 40 fps frame-sequence verifier fails the pre-fix +owner-pak capture on the exact disk (`radial coverage=0.053`). Three consecutive +fixed launches, using `artifacts/owner-gate/acdream-v5.pak` with the installed +DATs and the same ACE login route, swap directly to world and pass at minimum +tail coverage `0.800`, `0.747`, and `0.736`. Focused regressions cover the 1001 +table boundary, tunnel-retire-before-view-plane publication, and both +teleport/login host-call orders. No portal mesh, shader, sampler, lighting, or +camera-position change is part of the fix. + +**16:9 perimeter correction (2026-08-25):** the center-ring verifier missed +the same mesh boundary crossing the sides of a full-width viewport. The owner +capture and the automated 1280x720 sequence agree: level 1001 is the last +fully covered tunnel sample, level 1006 begins exposing the outer perimeter, +and the old level-1013 handoff can hold the complete faceted rim while the +world viewport is installed. The sequencer cutoff is therefore 1001, still in +the retail quantized table domain, and the gate now measures an unobstructed +left-edge rail in addition to center rings. This changes only which authored +tunnel sample is held for the atomic swap; it does not enlarge or repaint the +mesh. + +**Residual found by the owner:** the direct swap still leaves a large blue +lower-viewport region during the first `WorldFadeIn` samples. This is not a +missing tunnel texture and the title's `lb 0/0` is not a terrain-residency +count. The destination is resident; its finite terrain is projected with +`M22=0.001` and `znear=0.1`, so lower-screen ground rays meet the terrain before +the Vulkan near plane and only a thin horizon strip survives. The legacy retail +landscape visibly supplies coverage at this singular endpoint. The modern +renderer adaptation scales the near plane with view-plane distance only while +the world viewport owns `WorldFadeOut`/`WorldFadeIn`; tunnel and ordinary-world +near planes remain unchanged. The lower-viewport coverage gate passes the +owner-pak live capture, and the owner accepted the final in-game transition on +2026-08-25 as "Perfect!". + ## #418 — Login world load takes ~27 s: publication advances at a flat 32 blocks/s **Status:** IN-PROGRESS 2026-08-17 — producer half landed (this commit's @@ -1706,6 +1771,19 @@ attributed to the cold render-thread barrier); portal-hold gate-ready render-thread upload/registration phase (t≈1–8 s, concurrent) — budgets are exonerated three times over.** +**2026-08-25 attribution checkpoint:** `ACDREAM_PROBE_REVEAL_TIMING=1` now +pairs each reveal timing run with low-frequency `[reveal-resource]` snapshots +at begin, readiness edges, one-second progress intervals, and summary. The +snapshots borrow the canonical render owners and report mesh/atlas residency, +global and per-frame upload counts/bytes, buffer/texture/copy work, staging +high-water, mesh-arena capacity/migration, prepared-package probe/read results, +composite backlog, CPU mesh cache, and managed/committed/tracked GPU memory. +Use it with `ACDREAM_FRAME_PROF=1` and +`ACDREAM_FRAME_HISTORY=`; the next cold login comparison can now +distinguish decode/cache fill, upload/staging, +registration/composite debt, arena growth, and process-memory growth without +adding a per-frame diagnostic tax. + ## #417 — World ambience keeps playing (and re-firing) on the character-select screen after the in-world logoff **Status:** ✅ FIXED 2026-08-17 (logout-audio round; fix + tests in the same diff --git a/docs/launch-options.md b/docs/launch-options.md index 5c1d0e3c..109ebc76 100644 --- a/docs/launch-options.md +++ b/docs/launch-options.md @@ -258,7 +258,8 @@ $env:ACDREAM_FRAME_HISTORY = "$scratch\frames.csv" | `ACDREAM_PROBE_NET` | `="1"` | Emits `[net-out]` (per outbound reliable message), `[net-tick]` (1 Hz WorldSession.Tick summary incl. reliable-transport rates), `[net-final]` (cumulative stats at Dispose), and `[cmd-gate]` (generation-gated command rejections) | print-only. Doc comment: "the counters themselves increment unconditionally in `TransportStats`; only the string work is gated" — i.e. the underlying stats tracking has a small always-on cost independent of this flag, but this flag itself gates only string/console formatting. | unset (off) | `NetDiagnostics.ProbeNet` (`NetDiagnostics.cs:56-57`), issue #260 probe family | | `ACDREAM_PROBE_RESOLVE` | `=1` | gates one structured `[resolve]` line per `PhysicsEngine.ResolveWithTransition` call (in/target/out position+cell, ok-vs-partial, grounded/contact status, wall normal, walkable-polygon validity, responsible entity) (l.2a slice 1, general-purpose resolver probe) | print-only, ~30 Hz per moving entity while on | off | `PhysicsDiagnostics.ProbeResolveEnabled` | | `ACDREAM_PROBE_REVEAL` | `="1"` | While a reveal destination's composite warmup is incomplete, emits one `[composite-warmup]` line/second: pending queue depth, scan state, upload-budget gate, first few unresolved GfxObj ids | print-only | unset (off) | `NetDiagnostics.ProbeReveal` (`NetDiagnostics.cs:115-116`), issue #260 | -| `ACDREAM_PROBE_REVEAL_TIMING` | `="1"` | Wall-clock attribution of each login/portal reveal hold: `[reveal-timing]` lines for `begin`/first-true readiness edges (render/composites/collision/gate/materialized) with elapsed ms, 1 Hz progress, and one `SUMMARY` line at viewport reveal | print-only; doc comment: "never constructed unless the probe env is set, changes no behavior, and costs one branch per `Evaluate` poll otherwise" (i.e. genuinely near-zero cost when off — confirmed, `RevealTimingProbe` object itself is null when disabled) | unset (off) | `StreamingDiagnostics.ProbeRevealTiming` (`StreamingDiagnostics.cs:65-66`), consumed by `LandblockPresentationPipeline.cs:69`, `PublicationTimingProbe.cs:34`, `RevealTimingProbe.cs` (construction gated) | +| `ACDREAM_PROBE_REVEAL_TIMING` | `="1"` | Wall-clock attribution of each login/portal reveal hold: `[reveal-timing]` lines for `begin`/first-true readiness edges (render/composites/collision/gate/materialized), 1 Hz progress, and one `SUMMARY` line at viewport reveal; paired low-frequency `[reveal-resource]` snapshots report mesh staging/uploads/arena state, prepared-asset activity, composite warmup/uploads, managed memory, and tracked GPU residency | print-only; the probe object and render-resource sampler are not constructed when unset. When enabled, canonical resource owners are sampled only at begin, readiness edges, 1 Hz progress, and summary—not every frame. Use with `ACDREAM_FRAME_PROF=1` / `ACDREAM_FRAME_HISTORY` for per-frame CPU/GPU/alloc timing. | unset (off) | `StreamingDiagnostics.ProbeRevealTiming`, `RevealTimingProbe`, `RuntimeRenderFrameResourceDiagnosticsSource`, `PublicationTimingProbe` | +| `ACDREAM_PROBE_TUNNEL_FREEZE` | `=1` or `=N` | #419 RenderDoc apparatus: holds the teleport state in stable `Tunnel` after destination readiness and freezes the portal-space animation/roll at frame 72 (`=1`) or an explicit frame 2–120 (`=N`); emits one `[tunnel-freeze]` line with the actual frame and retail Setup/animation ids | **behavior-changing diagnostic:** placement, world viewport reveal, and LoginComplete are intentionally withheld until transition cancellation/process exit. For static visual inspection only; never use in a performance or lifecycle measurement. | unset (off) | `StreamingDiagnostics.TunnelFreezeFrame`; consumed by `LocalPlayerTeleportPresentation` and `PortalTunnelPresentation` | | `ACDREAM_PROBE_SOUND_WIRE` | `="1"` | One line per inbound server Sound event (`0xF750`) and per wire-sound play decision, with the drop reason when nothing plays — used to determine whether missing interior soundscapes are server- or client-side | print-only, consumed at `AudioHookSink.cs:159` and `EntityEffectController.cs:123` | unset (off) | `AudioDiagnostics.ProbeWireSoundsEnabled` (`AudioDiagnostics.cs:20-21`) | | `ACDREAM_PROBE_USEABILITY_FALLBACK` | `=1` | gates a per-call log of `IsUseableTarget` calls that take the null-useability fallback path (creature/door/lifestone passes) (measures a real ace-vs-retail data gap, not a bug investigation) | print-only; measures how often ACE ships entities without `_useability` set | off | `PhysicsDiagnostics.ProbeUseabilityFallbackEnabled` | | `ACDREAM_PROBE_VIS` | `=1` | emits `[vis]` line on root-cell CHANGE: visible cell ids, OutsideView poly/plane counts, per-cell plane counts, scissor-fallback count (phase u.2d repurposed the flag; its DebugPanel mirror is unreachable — #434) | print-only; ALSO implicitly enables the separate `ACDREAM_PROBE_ENVCELL` probe (its getter ORs with this flag — see Notes #3); startup-only in practice (its DebugPanel/DebugVM mirror is unreachable — #434)| false (off) | `RenderingDiagnostics.ProbeVisibilityEnabled` | diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 14fbe652..5f934e36 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -415,6 +415,23 @@ internal sealed class SessionPlayerCompositionPhase ? revealMeshes.SetDestinationRevealUploadPriority : static _ => { }, foundation.TextureCache.SetDestinationRevealUploadPriority); + IRenderFrameResourceDiagnosticsSource? revealResourceDiagnostics = + StreamingDiagnostics.ProbeRevealTiming + ? new RuntimeRenderFrameResourceDiagnosticsSource( + particles: null, + particleBindings: null, + worldDispatcher: revealDispatcher, + environmentCells: null, + particleRenderer: null, + uiTextRenderer: null, + portalDepthMask: null, + clipFrame: null, + terrain: null, + lighting: null, + meshes: foundation.MeshAdapter, + textures: foundation.TextureCache, + preparedAssets: content.PreparedAssets) + : null; var worldReveal = new WorldRevealCoordinator( live.WorldTransit, // #280: read the radii LIVE from the streaming controller rather @@ -450,7 +467,8 @@ internal sealed class SessionPlayerCompositionPhase worldQuiescence, streaming, revealRenderResources, - () => live.WorldState.LoadedLandblockCount); + () => live.WorldState.LoadedLandblockCount, + revealResourceDiagnostics); Fault(SessionPlayerCompositionPoint.WorldRevealCreated); return CompleteSessionPlayer( diff --git a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs index ba4614cf..c1f0fea3 100644 --- a/src/AcDream.App/Rendering/PortalTunnelPresentation.cs +++ b/src/AcDream.App/Rendering/PortalTunnelPresentation.cs @@ -1,6 +1,7 @@ using System.Numerics; using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Wb; +using AcDream.App.Streaming; using AcDream.App.UI; using AcDream.Content.Vfx; using AcDream.Core.Lighting; @@ -96,6 +97,7 @@ public sealed class PortalTunnelPresentation : IDisposable private float _rotationEndAngle; private float _rotationCurrentAngle; private bool _waitCueVisible; + private bool _probeFreezeReached; private bool _disposeRequested; private bool _disposing; private bool _disposed; @@ -256,6 +258,7 @@ public sealed class PortalTunnelPresentation : IDisposable _rotationCurrentAngle = 0f; _camera.DirectionDegrees = 0f; _waitCueVisible = false; + _probeFreezeReached = false; _visible = true; RebuildPose(); } @@ -267,6 +270,7 @@ public sealed class PortalTunnelPresentation : IDisposable return; _visible = false; _waitCueVisible = false; + _probeFreezeReached = false; _animationHooks.Clear(); _sequence.ClearAnimations(); } @@ -275,11 +279,25 @@ public sealed class PortalTunnelPresentation : IDisposable { if (!_visible || dt < 0f) return; + if (_probeFreezeReached) + return; _sequence.Update(dt, frame: null); RebuildPose(); _animationHooks.Drain(Vector3.Zero); TickRotation(dt); + + if (StreamingDiagnostics.TunnelFreezeFrame is not { } freezeFrame + || CurrentAnimationFrame < freezeFrame) + { + return; + } + + _probeFreezeReached = true; + Console.WriteLine( + $"[tunnel-freeze] frame={CurrentAnimationFrame} " + + $"target={freezeFrame} setup=0x{_setupDid:X8} " + + $"animation=0x{_animationDid:X8} state=held"); } /// diff --git a/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs b/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs index 7af1b277..b9a331fe 100644 --- a/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs +++ b/src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs @@ -128,9 +128,10 @@ internal readonly record struct ProcessResourceDiagnostics( int TrackedGpuTextures); /// -/// Immutable resource facts captured only when explicit UI-probe dumping is enabled. -/// Grouping keeps the diagnostics controller independent from every canonical renderer, -/// VFX, mesh, texture, and process owner used to produce the values. +/// Immutable resource facts captured only by an explicit low-frequency +/// diagnostic such as UI-probe dumping or reveal timing. Grouping keeps the +/// consumers independent from every canonical renderer, VFX, mesh, texture, +/// and process owner used to produce the values. /// internal readonly record struct RenderFrameResourceDiagnosticsSnapshot( VfxStreamResourceDiagnostics Vfx, diff --git a/src/AcDream.App/Rendering/TeleportViewPlaneController.cs b/src/AcDream.App/Rendering/TeleportViewPlaneController.cs index 01c50711..f3958091 100644 --- a/src/AcDream.App/Rendering/TeleportViewPlaneController.cs +++ b/src/AcDream.App/Rendering/TeleportViewPlaneController.cs @@ -17,6 +17,7 @@ public sealed class TeleportViewPlaneController public const float TransitionViewPlaneDistance = 0.001f; private float _gameViewPlaneDistance = 1f; + private TeleportAnimState _state = TeleportAnimState.Off; private readonly ProjectionOverrideCamera _projectionCamera = new(); public bool Enabled { get; private set; } @@ -36,6 +37,7 @@ public sealed class TeleportViewPlaneController _gameViewPlaneDistance = distance; CurrentViewPlaneDistance = distance; + _state = TeleportAnimState.Off; Enabled = false; } @@ -48,6 +50,7 @@ public sealed class TeleportViewPlaneController /// public void Update(TeleportAnimSnapshot snapshot) { + _state = snapshot.State; switch (snapshot.State) { case TeleportAnimState.WorldFadeOut: @@ -80,6 +83,7 @@ public sealed class TeleportViewPlaneController public void Reset() { + _state = TeleportAnimState.Off; Enabled = false; CurrentViewPlaneDistance = _gameViewPlaneDistance; } @@ -110,13 +114,35 @@ public sealed class TeleportViewPlaneController float distance = MathF.Max(CurrentViewPlaneDistance, TransitionViewPlaneDistance); float fov = 2f * MathF.Atan(1f / distance); - float near = MathF.Max(0.1f, distance * 0.25f); + float near = WorldTransitionNearPlane(distance); if (near >= far) near = MathF.Min(0.1f, far * 0.5f); return Matrix4x4.CreatePerspectiveFieldOfView(fov, aspect, near, far); } + /// + /// Retail's set_vdst keeps znear at 0.1 m below a view-plane + /// distance of 0.4. The legacy landscape path still supplied a complete + /// projected screen at the singular teleport endpoint. Vulkan clips the + /// finite resident terrain geometrically: at distance 0.001, every lower- + /// viewport ground ray hits the terrain before 0.1 m and the authored sky + /// is exposed underneath it. Scale the near plane with the transition only + /// while the world viewport owns the frame. This preserves the exact retail + /// X/Y warp, the tunnel projection, and the ordinary game projection while + /// giving the modern terrain path the coverage retail visibly produced. + /// + private float WorldTransitionNearPlane(float distance) + { + if (_state is TeleportAnimState.WorldFadeOut or TeleportAnimState.WorldFadeIn + && distance < 0.4f) + { + return MathF.Max(0.0001f, distance * 0.25f); + } + + return MathF.Max(0.1f, distance * 0.25f); + } + /// /// Decorate the active camera with the same projection returned by /// . Retail's Render::set_vdst is diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs index 6fc79add..480e3f7a 100644 --- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs +++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs @@ -555,7 +555,20 @@ internal sealed class LocalPlayerTeleportPresentation var (snapshot, events) = _animation.Tick( deltaSeconds, worldReady, - CurrentTunnelFrame); + CurrentTunnelFrame, + holdInTunnel: StreamingDiagnostics.TunnelFreezeFrame.HasValue); + + // Retail hides portal space before publishing the WorldFadeIn view + // plane (gmSmartBoxUI::UseTime @ 0x004D73D3). Keep that viewport + // swap inside the presentation update: the controller's exit event + // performs host work, and allowing the terminal projection to escape + // while the tunnel remains visible exposes the finite portal mesh as + // a one-frame faceted disk. Hiding first is also safe if a render + // boundary lands between these two writes: the destination world is + // already materialized and receives the old outgoing projection. + if (!snapshot.ShowTunnel && _tunnel.IsVisible) + _tunnel.Exit(); + _viewPlane.Update(snapshot); return (snapshot, events); } @@ -1228,16 +1241,19 @@ internal sealed class LocalPlayerTeleportController return; break; case TeleportAnimEvent.PlayExitSound: - // gmSmartBoxUI::UseTime @ 0x004D6E30 releases destination - // cell blocking at the exact portal/world viewport swap. - // LoginComplete remains one WorldFadeIn second later. + // Retail first hides portal space, then shows the world, + // and only afterwards plays Sound_UI_ExitPortal + // (gmSmartBoxUI::UseTime @ 0x004D73D3..0x004D7405). + // ExitTunnel is idempotent: the production presentation + // normally retired it before publishing this snapshot, + // while this call enforces the same order for every host. + _presentation.ExitTunnel(); + if (!IsCurrentLifetime(generation, sequence)) + return; _worldReveal.RevealWorldViewport(); if (!IsCurrentLifetime(generation, sequence)) return; _presentation.PlayExitCue(); - if (!IsCurrentLifetime(generation, sequence)) - return; - _presentation.ExitTunnel(); if (!IsCurrentLifetime(generation, sequence)) return; break; @@ -1707,17 +1723,16 @@ internal sealed class LocalPlayerTeleportController return; break; case TeleportAnimEvent.PlayExitSound: - // Release destination cell blocking at the exact - // portal/world viewport swap — same edge as the teleport - // pump (gmSmartBoxUI::UseTime @ 0x004D6E30), with - // Sound_UI_ExitPortal @ 0x004D7405. + // Identical retail viewport order to the teleport pump: + // hide portal, show/release the destination world, then + // play Sound_UI_ExitPortal @ 0x004D7405. + _presentation.ExitTunnel(); + if (!IsCurrentLoginLifetime(generation, revealGeneration)) + return; _worldReveal.RevealWorldViewport(); if (!IsCurrentLoginLifetime(generation, revealGeneration)) return; _presentation.PlayExitCue(); - if (!IsCurrentLoginLifetime(generation, revealGeneration)) - return; - _presentation.ExitTunnel(); if (!IsCurrentLoginLifetime(generation, revealGeneration)) return; break; diff --git a/src/AcDream.App/Streaming/RevealTimingProbe.cs b/src/AcDream.App/Streaming/RevealTimingProbe.cs index 3dd942bb..97f1f051 100644 --- a/src/AcDream.App/Streaming/RevealTimingProbe.cs +++ b/src/AcDream.App/Streaming/RevealTimingProbe.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using AcDream.App.Rendering; using AcDream.Runtime; namespace AcDream.App.Streaming; @@ -20,7 +21,9 @@ namespace AcDream.App.Streaming; /// shape. /// 1 Hz progress — elapsed, the three flags, and the resident /// landblock count, so a budget-paced linear drip is visually obvious in the -/// log. +/// log. A paired [reveal-resource] snapshot attributes the cold render +/// barrier to mesh preparation/upload, arena growth, texture composites, or +/// process/GPU residency without sampling those owners every frame. /// SUMMARY once at the viewport reveal — the per-edge /// timeline on one line. /// @@ -31,6 +34,7 @@ namespace AcDream.App.Streaming; internal sealed class RevealTimingProbe { private readonly Func? _loadedLandblockCount; + private readonly IRenderFrameResourceDiagnosticsSource? _renderResources; private readonly Stopwatch _clock = new(); private long _generation; private string _kind = ""; @@ -49,8 +53,13 @@ internal sealed class RevealTimingProbe private long _lastProgressMs; private int _framesSinceProgress; - public RevealTimingProbe(Func? loadedLandblockCount) => + public RevealTimingProbe( + Func? loadedLandblockCount, + IRenderFrameResourceDiagnosticsSource? renderResources = null) + { _loadedLandblockCount = loadedLandblockCount; + _renderResources = renderResources; + } public void Begin( string kind, @@ -80,6 +89,7 @@ internal sealed class RevealTimingProbe + $"cell=0x{destinationCell:X8} window={window.NearRadius}/" + $"{window.FarRadius} landblocks={_windowLandblocks} " + $"loaded={_loadedLandblockCount?.Invoke() ?? -1}"); + EmitResourceSnapshot("begin", elapsedMilliseconds: 0); } public void Observe( @@ -91,37 +101,56 @@ internal sealed class RevealTimingProbe _framesSinceProgress++; long elapsed = _clock.ElapsedMilliseconds; + string? resourceCheckpoint = null; if (!_render && readiness.IsRenderNeighborhoodReady) { _render = true; _renderMs = elapsed; Edge("render-ready", elapsed); + resourceCheckpoint = AppendCheckpoint( + resourceCheckpoint, + "render-ready"); } if (!_composites && readiness.AreCompositeTexturesReady) { _composites = true; _compositesMs = elapsed; Edge("composites-ready", elapsed); + resourceCheckpoint = AppendCheckpoint( + resourceCheckpoint, + "composites-ready"); } if (!_collision && readiness.IsCollisionReady) { _collision = true; _collisionMs = elapsed; Edge("collision-ready", elapsed); + resourceCheckpoint = AppendCheckpoint( + resourceCheckpoint, + "collision-ready"); } if (!_gateReady && readiness.IsReady) { _gateReady = true; _gateReadyMs = elapsed; Edge("gate-ready", elapsed); + resourceCheckpoint = AppendCheckpoint( + resourceCheckpoint, + "gate-ready"); } if (!_materialized && portal.Materialized) { _materialized = true; _materializedMs = elapsed; Edge("materialized", elapsed); + resourceCheckpoint = AppendCheckpoint( + resourceCheckpoint, + "materialized"); } + if (resourceCheckpoint is not null) + EmitResourceSnapshot(resourceCheckpoint, elapsed); + if (!_summarized && portal.WorldViewportObserved) { _summarized = true; @@ -132,6 +161,7 @@ internal sealed class RevealTimingProbe + $"gateReadyMs={_gateReadyMs} " + $"materializedMs={_materializedMs} " + $"landblocks={_windowLandblocks}"); + EmitResourceSnapshot("summary", elapsed); return; } @@ -151,6 +181,7 @@ internal sealed class RevealTimingProbe + $"loaded={_loadedLandblockCount?.Invoke() ?? -1}" + $"/{_windowLandblocks} " + $"frames={_framesSinceProgress}"); + EmitResourceSnapshot("progress", elapsed); PublicationTimingProbe.EmitStreamingTickWindow(); _framesSinceProgress = 0; } @@ -162,4 +193,77 @@ internal sealed class RevealTimingProbe + $"elapsedMs={elapsed} " + $"loaded={_loadedLandblockCount?.Invoke() ?? -1}" + $"/{_windowLandblocks}"); + + private void EmitResourceSnapshot(string checkpoint, long elapsedMilliseconds) + { + if (_renderResources is null) + return; + + RenderFrameResourceDiagnosticsSnapshot snapshot = + _renderResources.Capture(); + Console.WriteLine(FormatResourceLine( + checkpoint, + _kind, + _generation, + elapsedMilliseconds, + snapshot)); + } + + private static string AppendCheckpoint(string? current, string next) => + current is null ? next : current + "+" + next; + + internal static string FormatResourceLine( + string checkpoint, + string kind, + long generation, + long elapsedMilliseconds, + in RenderFrameResourceDiagnosticsSnapshot snapshot) + { + MeshStreamResourceDiagnostics mesh = snapshot.Mesh; + TextureStreamResourceDiagnostics textures = snapshot.Textures; + ProcessResourceDiagnostics process = snapshot.Process; + return $"[reveal-resource] checkpoint={checkpoint} kind={kind} " + + $"gen={generation} elapsedMs={elapsedMilliseconds} " + + $"meshData={mesh.RenderData} meshAtlases={mesh.AtlasArrays} " + + $"meshUnusedLru={mesh.UnusedLru} meshBytes={mesh.EstimatedBytes} " + + $"globalUploads={mesh.GlobalUploadCount} " + + $"globalUploadBytes={mesh.GlobalUploadedBytes} " + + $"frameUploads={mesh.FrameUploadCount} " + + $"frameUploadBytes={mesh.FrameUploadBytes} " + + $"frameArrayBytes={mesh.FrameArrayAllocationBytes} " + + $"frameMipmapBytes={mesh.FrameMipmapBytes} " + + $"frameBufferUploadBytes={mesh.FrameBufferUploadBytes} " + + $"frameBufferAllocationBytes={mesh.FrameBufferAllocationBytes} " + + $"frameBufferCopyBytes={mesh.FrameBufferCopyBytes} " + + $"frameNewArrays={mesh.FrameNewArrayCount} " + + $"frameNewBuffers={mesh.FrameNewBufferCount} " + + $"frameStaleDiscards={mesh.FrameStaleDiscardCount} " + + $"frameMipmapArrays={mesh.FrameMipmapArrayCount} " + + $"staged={mesh.StagedUploadBacklog} " + + $"stagedBytes={mesh.StagedUploadBytes} " + + $"stagingHighWater={(mesh.StagingAtHighWater ? 1 : 0)} " + + $"cpuMeshCache={mesh.CpuMeshCacheCount} " + + $"cpuMeshCacheBytes={mesh.CpuMeshCacheBytes} " + + $"arenaCapacityBytes={mesh.GlobalCapacityBytes} " + + $"arenaPhysicalBytes={mesh.GlobalPhysicalCapacityBytes} " + + $"arenaMigrating={(mesh.GlobalMigrationInProgress ? 1 : 0)} " + + $"prepared={mesh.PreparedProbes}/{mesh.PreparedReads}/" + + $"{mesh.PreparedLoaded}/{mesh.PreparedMissing}/" + + $"{mesh.PreparedCorrupt} " + + $"ownedTextures={textures.OwnedBindlessTextures} " + + $"textureOwners={textures.TextureOwners} " + + $"composites={textures.CachedCompositeTextures} " + + $"unownedComposites={textures.CachedUnownedComposites} " + + $"unownedCompositeBytes={textures.CachedUnownedCompositeBytes} " + + $"compositeAtlases={textures.CompositeAtlases} " + + $"compositeAtlasBytes={textures.CompositeAtlasBytes} " + + $"compositePending={textures.CompositeWarmupPending} " + + $"frameCompositeUploads={textures.FrameCompositeUploadCount} " + + $"frameCompositeUploadBytes={textures.FrameCompositeUploadBytes} " + + $"managedBytes={process.ManagedBytes} " + + $"managedCommittedBytes={process.ManagedCommittedBytes} " + + $"trackedGpuBytes={process.TrackedGpuBytes} " + + $"trackedGpuBuffers={process.TrackedGpuBuffers} " + + $"trackedGpuTextures={process.TrackedGpuTextures}"; + } } diff --git a/src/AcDream.App/Streaming/StreamingDiagnostics.cs b/src/AcDream.App/Streaming/StreamingDiagnostics.cs index 25f09017..f1b9dc00 100644 --- a/src/AcDream.App/Streaming/StreamingDiagnostics.cs +++ b/src/AcDream.App/Streaming/StreamingDiagnostics.cs @@ -1,5 +1,6 @@ using System.Globalization; using System; +using AcDream.Core.World; namespace AcDream.App.Streaming; @@ -11,6 +12,8 @@ namespace AcDream.App.Streaming; /// internal static class StreamingDiagnostics { + internal const int DefaultTunnelFreezeFrame = 72; + /// /// #280 A/B measurement probe. When set, the outdoor reveal gate uses this /// landblock radius instead of the derived streaming window, so the same @@ -65,6 +68,20 @@ internal static class StreamingDiagnostics public static bool ProbeRevealTiming { get; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_REVEAL_TIMING") == "1"; + /// + /// #419 RenderDoc apparatus. When present, the teleport sequencer remains + /// in retail's stable Tunnel state even after the destination is + /// ready, and the portal-space animation stops once it reaches this frame. + /// ACDREAM_PROBE_TUNNEL_FREEZE=1 selects the representative frame + /// ; an explicit frame in the range + /// 2.. may be supplied + /// instead. This intentionally prevents placement and viewport reveal + /// until the transition is cancelled or the process exits. It is + /// inspection apparatus, not a user setting. + /// + public static int? TunnelFreezeFrame { get; } = ParseTunnelFreezeFrame( + Environment.GetEnvironmentVariable("ACDREAM_PROBE_TUNNEL_FREEZE")); + /// /// The floor is 1, not 0. An outdoor destination's acknowledgement must /// carry RequiredRenderRadius >= 1 or @@ -78,4 +95,23 @@ internal static class StreamingDiagnostics && value >= 1 ? value : null; + + internal static int? ParseTunnelFreezeFrame(string? raw) + { + if (string.Equals(raw, "1", StringComparison.Ordinal) + || string.Equals(raw, "true", StringComparison.OrdinalIgnoreCase)) + { + return DefaultTunnelFreezeFrame; + } + + return int.TryParse( + raw, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out int frame) + && frame >= 2 + && frame <= TeleportAnimSequencer.TunnelEndFrame + ? frame + : null; + } } diff --git a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs index 8e74ab1e..689825b5 100644 --- a/src/AcDream.App/Streaming/WorldRevealCoordinator.cs +++ b/src/AcDream.App/Streaming/WorldRevealCoordinator.cs @@ -1,3 +1,4 @@ +using AcDream.App.Rendering; using AcDream.Runtime; using AcDream.Runtime.World; @@ -85,7 +86,8 @@ internal sealed class WorldRevealCoordinator WorldGenerationQuiescence? quiescence = null, IWorldRevealStreamingScheduler? streaming = null, IWorldRevealRenderResourceScheduler? renderResources = null, - Func? loadedLandblockCount = null) + Func? loadedLandblockCount = null, + IRenderFrameResourceDiagnosticsSource? renderResourceDiagnostics = null) { _transit = transit ?? throw new ArgumentNullException(nameof(transit)); _readiness = new WorldRevealReadinessBarrier( @@ -101,7 +103,11 @@ internal sealed class WorldRevealCoordinator _streaming = streaming; _renderResources = renderResources; if (StreamingDiagnostics.ProbeRevealTiming) - _timing = new RevealTimingProbe(loadedLandblockCount); + { + _timing = new RevealTimingProbe( + loadedLandblockCount, + renderResourceDiagnostics); + } } public RuntimePortalSnapshot Snapshot => _transit.Snapshot; diff --git a/src/AcDream.Core/World/TeleportAnimSequencer.cs b/src/AcDream.Core/World/TeleportAnimSequencer.cs index 1af2ce57..30c2ee7d 100644 --- a/src/AcDream.Core/World/TeleportAnimSequencer.cs +++ b/src/AcDream.Core/World/TeleportAnimSequencer.cs @@ -53,7 +53,13 @@ public sealed class TeleportAnimSequencer public const int TunnelEndFrame = 120; public const float ExitWindowLow = FadeTime + 0.1f; public const float ExitWindowHigh = FadeTime + 0.3f; - private const short LastVisibleOutgoingAnimationLevel = 1022; + // The retail viewport swap happens before the finite portal mesh reaches + // the final near-180-degree samples. Full-width 1280x720 captures establish + // level 1001 as the last radial field that covers the viewport; 1006 starts + // exposing the mesh perimeter and 1013 can leave its complete faceted rim + // on screen. Keep the cutoff in the quantized retail table domain so + // refresh rate cannot move the swap. + internal const short LastVisibleOutgoingAnimationLevel = 1001; // UIGlobals::Init @ 0x004EE470. Retail integrates 100 integer sine // samples into a 0..1024 easing table. @@ -102,10 +108,18 @@ public sealed class TeleportAnimSequencer /// Advance the machine by seconds. /// = the complete destination-load barrier is /// satisfied (Near-tier render meshes/textures plus collision residency). + /// is diagnostic apparatus: when true the + /// machine observes readiness but withholds the Place edge and stays + /// in the stable tunnel state. The default is false and retail behavior is + /// unchanged. /// Returns the current snapshot + edge-triggered events fired THIS tick. /// public (TeleportAnimSnapshot snapshot, IReadOnlyList events) - Tick(float dt, bool worldReady, int tunnelAnimationFrame = 72) + Tick( + float dt, + bool worldReady, + int tunnelAnimationFrame = 72, + bool holdInTunnel = false) { var evts = new List(); @@ -133,7 +147,7 @@ public sealed class TeleportAnimSequencer case TeleportAnimState.Tunnel: // Hold here until worldReady (EndTeleportAnimation analogue). - if (worldReady) + if (worldReady && !holdInTunnel) { evts.Add(TeleportAnimEvent.Place); Advance(TeleportAnimState.TunnelContinue, enterTunnel: false); @@ -183,10 +197,9 @@ public sealed class TeleportAnimSequencer /// /// Retire an outgoing viewport before the first quantized projection that - /// exposes the finite tunnel boundary. Retail's whole-frame black clear and - /// frame-paced D3D presentation show level 1022 as the last tunnel sample; - /// paired captures show no 1023/1024 portal frame before the world swap. - /// An uncapped modern loop can otherwise publish those sub-20 ms samples + /// exposes the finite tunnel boundary. Full-width captures show 1001 as + /// the last covered sample and 1006 as the first divergent faceted edge. + /// An uncapped modern loop can otherwise publish those terminal samples /// and let the desktop compositor hold one for a complete display refresh. /// private bool OutgoingViewportReachedTerminalProjection() diff --git a/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs b/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs index 47f548bb..99702c74 100644 --- a/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PortalTunnelAssetTests.cs @@ -1,4 +1,7 @@ +using System.Reflection; using AcDream.App.Rendering; +using AcDream.App.Streaming; +using AcDream.App.Tests.Architecture; using AcDream.App.UI; using AcDream.Content; using AcDream.Content.Vfx; @@ -12,6 +15,30 @@ namespace AcDream.App.Tests.Rendering; public sealed class PortalTunnelAssetTests { + [Fact] + public void PortalWorldHandoff_HidesTunnelBeforePublishingWorldFadeInProjection() + { + MethodInfo tick = typeof(LocalPlayerTeleportPresentation).GetMethod( + nameof(LocalPlayerTeleportPresentation.Tick), + BindingFlags.Instance | BindingFlags.Public) + ?? throw new InvalidOperationException("Presentation Tick method missing."); + IReadOnlyList calls = CompiledCallGraph.Read(tick); + int hide = CompiledCallGraph.IndexOf( + calls, + typeof(PortalTunnelPresentation), + nameof(PortalTunnelPresentation.Exit)); + int publish = CompiledCallGraph.IndexOf( + calls, + typeof(TeleportViewPlaneController), + nameof(TeleportViewPlaneController.Update)); + + Assert.True(hide >= 0, "Presentation Tick no longer retires portal space."); + Assert.True(publish >= 0, "Presentation Tick no longer publishes its view plane."); + Assert.True( + hide < publish, + "Portal space must retire before the WorldFadeIn projection is visible to rendering."); + } + /// /// Campaign V slice V6m. The RHI arm cannot inherit a bound framebuffer, so /// its pass re-establishes the colour the frame already cleared. That is @@ -189,7 +216,7 @@ public sealed class PortalTunnelAssetTests TeleportViewPlaneController.TransitionViewPlaneDistance, wideProjection.M22, precision: 5); - Assert.Equal(0.1f, wideProjection.M43 / wideProjection.M33, precision: 5); + Assert.Equal(0.00025f, wideProjection.M43 / wideProjection.M33, precision: 7); controller.Update(new TeleportAnimSnapshot( TeleportAnimState.Off, @@ -201,6 +228,37 @@ public sealed class PortalTunnelAssetTests Assert.Equal(baseProjection, controller.Apply(baseProjection)); } + [Fact] + public void TeleportViewPlane_TunnelKeepsRetailNearPlaneWhileWorldUsesCoverageNearPlane() + { + var baseProjection = System.Numerics.Matrix4x4.CreatePerspectiveFieldOfView( + MathF.PI / 3f, + 16f / 9f, + 0.1f, + 5000f); + var controller = new TeleportViewPlaneController(); + controller.Begin(baseProjection); + + controller.Update(new TeleportAnimSnapshot( + TeleportAnimState.TunnelFadeOut, + ViewPlaneBlend: 1f, + ShowTunnel: true, + ShowPleaseWait: false)); + System.Numerics.Matrix4x4 tunnelProjection = controller.Apply(baseProjection); + + controller.Update(new TeleportAnimSnapshot( + TeleportAnimState.WorldFadeIn, + ViewPlaneBlend: 1f, + ShowTunnel: false, + ShowPleaseWait: false)); + System.Numerics.Matrix4x4 worldProjection = controller.Apply(baseProjection); + + Assert.Equal(0.1f, tunnelProjection.M43 / tunnelProjection.M33, precision: 5); + Assert.Equal(0.00025f, worldProjection.M43 / worldProjection.M33, precision: 7); + Assert.Equal(tunnelProjection.M11, worldProjection.M11); + Assert.Equal(tunnelProjection.M22, worldProjection.M22); + } + [Fact] public void TeleportViewPlane_ApplyToSuppliesOverrideToEveryCameraConsumer() { diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs index c1e8f5d1..f176db45 100644 --- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs @@ -468,7 +468,7 @@ public sealed class LocalPlayerTeleportControllerTests } [Fact] - public void ExitSound_ReleasesDestinationBeforeHidingTunnelButKeepsProtocolActive() + public void ExitSound_HidesTunnelBeforeReleasingDestinationAndKeepsProtocolActive() { var order = new List(); var harness = new Harness(order: order); @@ -481,6 +481,8 @@ public sealed class LocalPlayerTeleportControllerTests harness.Controller.Tick(0.016f); + Assert.True(Index(order, "presentation-exit") < Index(order, "reservation-end")); + Assert.True(Index(order, "reservation-end") < Index(order, "exit-cue")); Assert.False(harness.Presentation.IsPortalViewportVisible); Assert.True(harness.Controller.IsActive); Assert.False(harness.Reveal.Snapshot.Completed); @@ -1589,10 +1591,13 @@ public sealed class LocalPlayerTeleportControllerTests harness.Controller.Tick(0.016f); Assert.False(harness.Placement.Called); - // Viewport swap: reservation release + exit cue + tunnel retire + // Viewport swap: tunnel retire, reservation release, then exit cue // (gmSmartBoxUI::UseTime, Sound_UI_ExitPortal @ 0x004D7405). + order.Clear(); harness.Presentation.Enqueue(TeleportAnimEvent.PlayExitSound); harness.Controller.Tick(0.016f); + Assert.True(Index(order, "presentation-exit") < Index(order, "reservation-end")); + Assert.True(Index(order, "reservation-end") < Index(order, "exit-cue")); Assert.Equal(["enter", "exit"], harness.Presentation.Cues); Assert.False(harness.Presentation.IsPortalViewportVisible); Assert.Single(harness.Streaming.ReservationEnds); @@ -2126,9 +2131,17 @@ public sealed class LocalPlayerTeleportControllerTests public void TickTunnel(float deltaSeconds) => _order.Add("tunnel-tick"); public readonly List Cues = new(); public void PlayEnterCue() => Cues.Add("enter"); - public void PlayExitCue() => Cues.Add("exit"); + public void PlayExitCue() + { + Cues.Add("exit"); + _order.Add("exit-cue"); + } public void EnterTunnel() => IsPortalViewportVisible = true; - public void ExitTunnel() => IsPortalViewportVisible = false; + public void ExitTunnel() + { + IsPortalViewportVisible = false; + _order.Add("presentation-exit"); + } public void SetWaitCue(bool visible) => WaitCueValues.Add(visible); public void Reset() diff --git a/tests/AcDream.App.Tests/Streaming/RevealTimingProbeTests.cs b/tests/AcDream.App.Tests/Streaming/RevealTimingProbeTests.cs new file mode 100644 index 00000000..c66baaf9 --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/RevealTimingProbeTests.cs @@ -0,0 +1,129 @@ +using AcDream.App.Rendering; +using AcDream.App.Streaming; +using AcDream.Runtime; + +namespace AcDream.App.Tests.Streaming; + +public sealed class RevealTimingProbeTests +{ + [Fact] + public void ResourceSampling_IsLowFrequencyAndCoalescesSamePollEdges() + { + var resources = new CountingResourceSource(); + var probe = new RevealTimingProbe( + loadedLandblockCount: static () => 4, + renderResources: resources); + probe.Begin( + kind: "Login", + generation: 2, + destinationCell: 0x1234_0001u, + window: new StreamingRevealWindow(NearRadius: 1, FarRadius: 2)); + + probe.Observe( + default, + RuntimePortalSnapshot.Idle with { Generation = 2 }); + probe.Observe( + new WorldRevealReadinessSnapshot( + DestinationCell: 0x1234_0001u, + IsIndoor: false, + IsUnhydratable: false, + RequiredRenderRadius: 2, + RequiredNearRadius: 1, + IsRenderNeighborhoodReady: true, + AreCompositeTexturesReady: true, + IsCollisionReady: true), + RuntimePortalSnapshot.Idle with + { + Generation = 2, + Materialized = true, + WorldViewportObserved = true, + }); + + // Begin, one coalesced readiness-edge snapshot, and summary. The + // ordinary false poll does not touch the renderer resource owners. + Assert.Equal(3, resources.CaptureCount); + } + + [Fact] + public void ResourceLineCarriesColdMeshTextureAndProcessAttribution() + { + MeshStreamResourceDiagnostics mesh = + default(MeshStreamResourceDiagnostics) with + { + RenderData = 12, + AtlasArrays = 3, + EstimatedBytes = 4_000, + GlobalUploadCount = 7, + GlobalUploadedBytes = 8_000, + FrameUploadCount = 2, + FrameUploadBytes = 900, + StagedUploadBacklog = 5, + StagedUploadBytes = 6_000, + GlobalCapacityBytes = 10_000, + GlobalPhysicalCapacityBytes = 20_000, + GlobalMigrationInProgress = true, + PreparedReads = 21, + PreparedLoaded = 20, + }; + TextureStreamResourceDiagnostics textures = + default(TextureStreamResourceDiagnostics) with + { + OwnedBindlessTextures = 17, + TextureOwners = 15, + CachedCompositeTextures = 9, + CachedUnownedComposites = 2, + CachedUnownedCompositeBytes = 700, + CompositeAtlases = 2, + CompositeAtlasBytes = 30_000, + CompositeWarmupPending = 4, + FrameCompositeUploadCount = 3, + FrameCompositeUploadBytes = 1_200, + }; + ProcessResourceDiagnostics process = new( + ManagedBytes: 40_000, + ManagedCommittedBytes: 50_000, + TrackedGpuBytes: 60_000, + TrackedGpuBuffers: 11, + TrackedGpuTextures: 13); + var snapshot = new RenderFrameResourceDiagnosticsSnapshot( + default, + default, + mesh, + textures, + process); + + string line = RevealTimingProbe.FormatResourceLine( + "render-ready", + "Login", + generation: 2, + elapsedMilliseconds: 3456, + snapshot); + + Assert.StartsWith( + "[reveal-resource] checkpoint=render-ready kind=Login gen=2 elapsedMs=3456", + line); + Assert.Contains("meshData=12", line); + Assert.Contains("globalUploads=7", line); + Assert.Contains("staged=5", line); + Assert.Contains("arenaMigrating=1", line); + Assert.Contains("prepared=0/21/20/0/0", line); + Assert.Contains("ownedTextures=17", line); + Assert.Contains("unownedCompositeBytes=700", line); + Assert.Contains("compositePending=4", line); + Assert.Contains("frameCompositeUploadBytes=1200", line); + Assert.Contains("managedBytes=40000", line); + Assert.Contains("trackedGpuBytes=60000", line); + } + + private sealed class CountingResourceSource : + IRenderFrameResourceDiagnosticsSource + { + public int CaptureCount { get; private set; } + + public RenderFrameResourceDiagnosticsSnapshot Capture() + { + CaptureCount++; + return default; + } + } +} diff --git a/tests/AcDream.App.Tests/Streaming/StreamingDiagnosticsTests.cs b/tests/AcDream.App.Tests/Streaming/StreamingDiagnosticsTests.cs new file mode 100644 index 00000000..093524cf --- /dev/null +++ b/tests/AcDream.App.Tests/Streaming/StreamingDiagnosticsTests.cs @@ -0,0 +1,23 @@ +using AcDream.App.Streaming; + +namespace AcDream.App.Tests.Streaming; + +public sealed class StreamingDiagnosticsTests +{ + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData("0", null)] + [InlineData("garbage", null)] + [InlineData("1", StreamingDiagnostics.DefaultTunnelFreezeFrame)] + [InlineData("true", StreamingDiagnostics.DefaultTunnelFreezeFrame)] + [InlineData("TRUE", StreamingDiagnostics.DefaultTunnelFreezeFrame)] + [InlineData("2", 2)] + [InlineData("72", 72)] + [InlineData("120", 120)] + [InlineData("121", null)] + public void TunnelFreezeParser_AcceptsTheDefaultAliasOrAnAuthoredFrame( + string? raw, + int? expected) => + Assert.Equal(expected, StreamingDiagnostics.ParseTunnelFreezeFrame(raw)); +} diff --git a/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs b/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs index ef0820d9..7f9a8525 100644 --- a/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs +++ b/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs @@ -168,6 +168,31 @@ public sealed class TeleportAnimSequencerTests Assert.Contains(TeleportAnimEvent.Place, evts); } + [Fact] + public void DiagnosticHold_KeepsReadyPortalInStableTunnelUntilReleased() + { + var seq = new TeleportAnimSequencer(); + seq.Begin(TeleportEntryKind.Portal); + + var (_, heldEvents) = seq.Tick( + dt: 30f, + worldReady: true, + tunnelAnimationFrame: 72, + holdInTunnel: true); + + Assert.Equal(TeleportAnimState.Tunnel, seq.State); + Assert.DoesNotContain(TeleportAnimEvent.Place, heldEvents); + + var (_, releasedEvents) = seq.Tick( + dt: 0f, + worldReady: true, + tunnelAnimationFrame: 72, + holdInTunnel: false); + + Assert.Equal(TeleportAnimState.TunnelContinue, seq.State); + Assert.Contains(TeleportAnimEvent.Place, releasedEvents); + } + // --- TunnelContinue: MIN_CONTINUE hold then TunnelFadeOut --- [Fact] @@ -301,6 +326,44 @@ public sealed class TeleportAnimSequencerTests framesPerSecond); } + [Fact] + public void TunnelFadeOut_SwapsAfterLastFullViewportCaptureLevel() + { + Assert.Equal((short)1001, TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel); + + var seq = new TeleportAnimSequencer(); + seq.Begin(TeleportEntryKind.Portal); + seq.Tick(0f, worldReady: false); + seq.Tick(0.016f, worldReady: true); + seq.Tick( + TeleportAnimSequencer.MinContinue, + worldReady: true, + tunnelAnimationFrame: 72); + Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State); + + float lastSafeTime = Enumerable.Range(0, 1001) + .Select(static i => i / 1000f) + .First(t => TeleportAnimSequencer.GetRetailAnimationLevel(t) + == TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel); + float firstUnsafeTime = Enumerable.Range(0, 1001) + .Select(static i => i / 1000f) + .First(t => TeleportAnimSequencer.GetRetailAnimationLevel(t) + > TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel); + + var (lastSafe, _) = seq.Tick(lastSafeTime, worldReady: true); + Assert.Equal(TeleportAnimState.TunnelFadeOut, lastSafe.State); + Assert.Equal( + TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel / 1024f, + lastSafe.ViewPlaneBlend); + + var (swapped, events) = seq.Tick( + firstUnsafeTime - lastSafeTime, + worldReady: true); + Assert.Equal(TeleportAnimState.WorldFadeIn, swapped.State); + Assert.False(swapped.ShowTunnel); + Assert.Contains(TeleportAnimEvent.PlayExitSound, events); + } + private static void AssertOutgoingViewportRetiresBeforeTerminal( TeleportAnimSequencer seq, TeleportAnimState outgoingState, @@ -314,7 +377,8 @@ public sealed class TeleportAnimSequencerTests if (snapshot.State == outgoingState) { Assert.True( - snapshot.ViewPlaneBlend <= 1022f / 1024f, + snapshot.ViewPlaneBlend + <= TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel / 1024f, $"Published unsafe outgoing blend {snapshot.ViewPlaneBlend}."); continue; } @@ -384,8 +448,13 @@ public sealed class TeleportAnimSequencerTests seq.Begin(TeleportEntryKind.Logout); seq.Tick(0f, worldReady: false); // consume enter-sound tick, elapsed≈0 - // Drive to just BEFORE the transition (so we're still in WorldFadeOut) - DriveSeconds(seq, TeleportAnimSequencer.FadeTime - 0.03f, worldReady: false); + float lastSafeTime = Enumerable.Range(0, 1001) + .Select(static i => i / 1000f) + .First(t => TeleportAnimSequencer.GetRetailAnimationLevel(t) + == TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel); + + // Drive directly to the last full-viewport quantized table level. + seq.Tick(lastSafeTime, worldReady: false); Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State); var (snap, _) = seq.Tick(0f, worldReady: false); @@ -393,7 +462,9 @@ public sealed class TeleportAnimSequencerTests Assert.True( snap.ViewPlaneBlend > 0.95f, $"Expected ViewPlaneBlend near 1, got {snap.ViewPlaneBlend}"); - Assert.True(snap.ViewPlaneBlend <= 1022f / 1024f); + Assert.True( + snap.ViewPlaneBlend + <= TeleportAnimSequencer.LastVisibleOutgoingAnimationLevel / 1024f); } [Fact] From f160f3fee19efafb087a6dc2a5c92222c2630f5e Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 19:16:53 +0200 Subject: [PATCH 78/89] fix(ui): wait for private viewport mesh residency --- docs/ISSUES.md | 43 +-- .../PrivateEntityViewportRenderer.cs | 271 +++++++++++++++--- ...ateEntityViewportRendererResidencyTests.cs | 225 +++++++++++++++ 3 files changed, 476 insertions(+), 63 deletions(-) create mode 100644 tests/AcDream.App.Tests/Rendering/PrivateEntityViewportRendererResidencyTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 24cec938..5bb2231a 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -26,8 +26,7 @@ What does NOT go here: ## #443 — Examination/paperdoll private viewport: doll appears only after a delay on first open (was: "renders nothing") -**Status:** OPEN (narrowed at the gate — intermittent first-open DELAY, not -a hard break; NOT gate-blocking, owner passed the Campaign AS gate with it). +**Status:** FIXED / OWNER-ACCEPTED 2026-08-25. **Component:** private entity viewports (examination clone, inventory paperdoll — shared `PrivateEntityViewportRenderer`). **Filed:** 2026-08-25, AS-GF1 gate-fix session. **Narrowed same day at the @@ -46,6 +45,20 @@ extras rows at small window heights) is the authored scroll-less clipped-list behavior AS-GF1 ruled retail-correct below, plus the clip line moving with resize — owner-accepted at the gate. +**Fix:** `PrivateEntityViewportRenderer.EntitySlot` now uses a two-phase +mesh-residency handoff. A replacement first acquires/pins its complete mesh +ownership set, remains pending while each drawable `MeshRef` crosses the +render-thread upload barrier, then atomically replaces the active entity and +texture-owner generation. While a replacement is pending the renderer keeps +the last completed viewport texture; on first open it publishes no texture so +the authored panel art remains visible instead of exposing a black-cleared +render target. The slot also detects GfxObj-id changes made in place by the +animated appraisal/chargen paths and supersedes stale pending owners without +leaking references. Focused paperdoll, appraisal, draw-order, synthetic-owner, +and new residency tests pass 30/30; the App hermetic lane passes 6,358/6,358. +The owner then live-verified repeated inventory and monster/player assessment +opens against the local ACE test server: "Good. works." + Owner report at the Campaign AS connected gate: the animated 3-D paperdoll in the examination window (LayoutDesc `0x2100006B` element `0x10000148`) worked correctly at baseline `974fe88a` (praised the same session) and was @@ -107,23 +120,15 @@ Campaign AS diff for either symptom:** presenter at all) are byte-for-byte UNCHANGED across the whole `974fe88a..87e98395` window (`git log -p` for both files is empty). -**Conclusion:** the trigger is one of `TryGetVisibleTarget`'s -`CurrentObjectId` check or `TrySynchronize`'s `LiveEntityRuntime. -TryGetWorldEntity`/`MeshRefs.Count` check, in code nothing in Campaign AS -touches — meaning either a pre-existing, previously-latent condition this -gate round happened to trigger, or a live/timing condition a hermetic test -cannot reproduce (no live entity, no live wire exchange). - -**Probe added this session** (temporary — delete with the real fix): -`ACDREAM_PROBE_CREATURE_APPRAISAL_VIEWPORT=1` — -`CreatureAppraisalViewportDiagnostics` in `CreatureAppraisalPresentation.cs` -logs `[AS-GF1-PROBE] creature-appraisal viewport: ` on every REASON -TRANSITION (not every frame) from both `TryGetVisibleTarget` and -`RetailCreatureAppraisalCloneFactory.TrySynchronize`. Next step: relaunch -with the flag set, examine a player, and read which one of the five -possible reasons (`no ActiveView`, `windowFrame hidden`, `viewport hidden`, -`no CurrentObjectId`, `entity not found`, `no MeshRefs`) fires — that -pinpoints the real fix. +**Conclusion after live recurrence:** the temporary probe ruled out every +higher-level gate: the target, clone, 34 MeshRefs, camera and nonzero texture +handle were all healthy while the pane was visibly empty. The shared slot had +published the clone immediately after `IncrementRefCount`, but that operation +only schedules asynchronous preparation/upload. The private pass then cleared +its target to black while `WbDrawDispatcher` skipped every nonresident mesh. +Residency/backlog timing explains both intermittent first-open delay and the +same symptom across inventory and monster/player examination. The probe was +deleted in `ddbd7e40` per the probe-dies rule; no diagnostic flag remains. ## #442 — Flake: DirectionalShadowCasterFrameTests.WarmDenseChangedFrames_AllocateZeroAndReadNoSceneRecords fails intermittently under full parallel suite load diff --git a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs index f293218b..a5bc2e6a 100644 --- a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs +++ b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs @@ -99,6 +99,7 @@ internal sealed class PrivateEntityViewportRenderer : private GpuTextureSlot _slot = GpuTextureSlot.Unassigned; private int _fbW; private int _fbH; + private bool _hasRenderedScene; public PrivateEntityViewportRenderer( IWorldPassScope scope, @@ -197,6 +198,23 @@ internal sealed class PrivateEntityViewportRenderer : /// public uint Render(int width, int height) { + // #443: acquiring a synthetic mesh reference only schedules CPU + // preparation/GPU upload; it does not make the mesh drawable. Keep the + // last completed private scene intact until every drawable mesh in the + // replacement has crossed that upload barrier. On first open there is + // no completed scene, so return zero and let the authored panel art + // show through instead of publishing a freshly-cleared black target. + bool mainReady = _mainSlot.PrepareForDraw(); + bool backdropReady = _backdropSlot?.PrepareForDraw() ?? true; + if (!mainReady || !backdropReady) + { + return _mainSlot.Entity is not null + && _hasRenderedScene + && _slot.IsAssigned + ? UiTextureTableHandle.FromSlot(_slot) + : 0u; + } + WorldEntity? entity = _mainSlot.Entity; if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0) return 0u; @@ -256,6 +274,7 @@ internal sealed class PrivateEntityViewportRenderer : neverCullLandblockId: PrivateLandblockId, visibleCellIds: null, animatedEntityIds: _animatedIds); + _hasRenderedScene = true; return UiTextureTableHandle.FromSlot(_slot); } @@ -367,6 +386,7 @@ internal sealed class PrivateEntityViewportRenderer : _target = null; _fbW = 0; _fbH = 0; + _hasRenderedScene = false; } public void Dispose() @@ -405,31 +425,88 @@ internal sealed class PrivateEntityViewportRenderer : } } - private static IEnumerable CollectMeshIds(WorldEntity entity) - { - for (int i = 0; i < entity.MeshRefs.Count; i++) - yield return entity.MeshRefs[i].GfxObjId; - for (int i = 0; i < entity.PartOverrides.Count; i++) - yield return entity.PartOverrides[i].GfxObjId; - } - /// - /// One private entity's own mesh-reference/texture-owner lifetime, - /// independent of any other slot on the same renderer. Factored out at - /// Campaign CC gate round 1 Batch D so the chargen backdrop entity gets - /// the EXACT SAME acquire/replace/retire behavior the main entity already - /// had — a single-owner class shared by both slots rather than a second, - /// hand-duplicated copy of 's - /// pre-Batch-D body. + /// One private entity's mesh-reference/texture-owner lifetime, independent + /// of every other slot on the renderer. Publication is two-phase: a candidate owns its mesh + /// references while preparation/upload runs, but does not replace the + /// drawable entity until all of its actual + /// are resident. Kept internal so #443's lifetime/readiness behavior can + /// be pinned without constructing a live GPU device. /// - private sealed class EntitySlot + internal sealed class EntitySlot { + private sealed class MeshSnapshot + { + private readonly ulong[] _ownedIds; + private readonly int _drawMeshCount; + + private MeshSnapshot(ulong[] ownedIds, int drawMeshCount) + { + _ownedIds = ownedIds; + _drawMeshCount = drawMeshCount; + } + + public IReadOnlyList OwnedIds => _ownedIds; + + public static MeshSnapshot Capture(WorldEntity entity) + { + int drawMeshCount = entity.MeshRefs.Count; + var ids = new ulong[drawMeshCount + entity.PartOverrides.Count]; + for (int i = 0; i < drawMeshCount; i++) + ids[i] = entity.MeshRefs[i].GfxObjId; + for (int i = 0; i < entity.PartOverrides.Count; i++) + ids[drawMeshCount + i] = entity.PartOverrides[i].GfxObjId; + return new MeshSnapshot(ids, drawMeshCount); + } + + /// + /// Residency identity deliberately excludes part transforms, + /// palette ranges and surface overrides: changing those does not + /// require another mesh upload. A different entity instance still + /// stages a replacement so its fixed texture owner is refreshed. + /// + public bool Matches(WorldEntity entity) + { + if (entity.MeshRefs.Count != _drawMeshCount + || entity.PartOverrides.Count != _ownedIds.Length - _drawMeshCount) + { + return false; + } + + for (int i = 0; i < _drawMeshCount; i++) + if (entity.MeshRefs[i].GfxObjId != _ownedIds[i]) + return false; + for (int i = 0; i < entity.PartOverrides.Count; i++) + if (entity.PartOverrides[i].GfxObjId != _ownedIds[_drawMeshCount + i]) + return false; + return true; + } + + public bool AreDrawMeshesReady(IWbMeshAdapter adapter) + { + for (int i = 0; i < _drawMeshCount; i++) + { + ulong id = _ownedIds[i]; + if (id == 0u || !adapter.IsRenderDataReady(id)) + return false; + } + return true; + } + } + + private sealed record PendingEntity( + WorldEntity Entity, + MeshSnapshot Snapshot, + SyntheticEntityMeshReferenceOwner MeshReferences); + private readonly IWbMeshAdapter _meshAdapter; private readonly FixedEntityTextureOwnerLease _textureOwnerLease; private readonly string _diagnosticName; private readonly List _retiringMeshReferences = []; private SyntheticEntityMeshReferenceOwner? _meshReferences; + private MeshSnapshot? _meshSnapshot; + private PendingEntity? _pending; public EntitySlot( IWbMeshAdapter meshAdapter, @@ -444,75 +521,181 @@ internal sealed class PrivateEntityViewportRenderer : public WorldEntity? Entity { get; private set; } + internal bool HasPending => _pending is not null; + public void Set(WorldEntity? entity) { ReleaseRetiringMeshReferences(); - if (ReferenceEquals(Entity, entity)) + if (entity is null) + { + Clear(); + return; + } + + // The common animated appraisal path reuses one clone object. Let + // PrepareForDraw perform its allocation-free mesh-id comparison; + // if no candidate is pending, reference equality is enough here. + if (ReferenceEquals(Entity, entity) && _pending is null) return; - SyntheticEntityMeshReferenceOwner? replacement = null; - if (entity is not null) + if (ReferenceEquals(Entity, entity) + && _meshSnapshot?.Matches(entity) == true) { - replacement = new SyntheticEntityMeshReferenceOwner( - _meshAdapter, - CollectMeshIds(entity)); - replacement.Acquire(); + ReleasePending(); + return; } - SyntheticEntityMeshReferenceOwner? previous = _meshReferences; + if (_pending is { } pending + && ReferenceEquals(pending.Entity, entity) + && pending.Snapshot.Matches(entity)) + { + return; + } + + Stage(entity); + } + + /// + /// Refreshes an in-place MeshRefs mutation, re-arms missing uploads, + /// and atomically promotes a fully drawable candidate. False tells the + /// renderer to preserve its last completed render target this frame. + /// + public bool PrepareForDraw() + { + ReleaseRetiringMeshReferences(); + + if (_pending is { } pending) + { + if (!pending.Snapshot.Matches(pending.Entity)) + Stage(pending.Entity); + } + else if (Entity is { } current + && _meshSnapshot?.Matches(current) != true) + { + // Chargen animation and appraisal synchronization both mutate + // a retained WorldEntity in place. Detect a changed GfxObj set + // here even when the caller did not issue another Set call. + Stage(current); + } + + pending = _pending; + if (pending is null) + return true; + if (!pending.Snapshot.AreDrawMeshesReady(_meshAdapter)) + return false; + + PromotePending(pending); + return true; + } + + private void Stage(WorldEntity entity) + { + MeshSnapshot snapshot = MeshSnapshot.Capture(entity); + var replacement = new SyntheticEntityMeshReferenceOwner( + _meshAdapter, + snapshot.OwnedIds); try { - _textureOwnerLease.Replace(entity is not null); + replacement.Acquire(); } - catch (Exception textureFailure) + catch (Exception acquisitionFailure) { - if (replacement is null) - throw; - try { replacement.Dispose(); } catch (Exception rollbackFailure) { + _retiringMeshReferences.Add(replacement); throw new AggregateException( - $"The {_diagnosticName} texture-owner replacement failed " - + "and the replacement mesh-owner rollback did not converge.", - textureFailure, + $"The {_diagnosticName} candidate mesh acquisition failed " + + "and its rollback did not converge.", + acquisitionFailure, rollbackFailure); } System.Runtime.ExceptionServices.ExceptionDispatchInfo - .Capture(textureFailure) + .Capture(acquisitionFailure) .Throw(); + throw new InvalidOperationException("Unreachable exception dispatch path."); } - _meshReferences = replacement; - Entity = entity; + PendingEntity? previous = _pending; + _pending = new PendingEntity(entity, snapshot, replacement); + if (previous is not null) + Retire(previous.MeshReferences); + } + + private void PromotePending(PendingEntity pending) + { + // Release the fixed texture owner's prior composites only at the + // same atomic edge that publishes the new mesh set. If release + // fails, the candidate remains pending and can retry intact. + _textureOwnerLease.Replace(hasReplacement: true); + + SyntheticEntityMeshReferenceOwner? previous = _meshReferences; + _meshReferences = pending.MeshReferences; + _meshSnapshot = pending.Snapshot; + Entity = pending.Entity; + _pending = null; if (previous is not null) + Retire(previous); + } + + private void Clear() + { + if (Entity is null && _pending is null) + return; + + ReleasePending(); + _textureOwnerLease.Replace(hasReplacement: false); + + SyntheticEntityMeshReferenceOwner? previous = _meshReferences; + _meshReferences = null; + _meshSnapshot = null; + Entity = null; + if (previous is not null) + Retire(previous); + } + + private void ReleasePending() + { + PendingEntity? pending = _pending; + if (pending is null) + return; + _pending = null; + Retire(pending.MeshReferences); + } + + private void Retire(SyntheticEntityMeshReferenceOwner owner) + { + try { - try - { - previous.Dispose(); - } - catch - { - _retiringMeshReferences.Add(previous); - throw; - } + owner.Dispose(); + } + catch + { + _retiringMeshReferences.Add(owner); + throw; } } public void Dispose() { Entity = null; + _meshSnapshot = null; if (_meshReferences is { } current) { _meshReferences = null; _retiringMeshReferences.Add(current); } + if (_pending is { } pending) + { + _pending = null; + _retiringMeshReferences.Add(pending.MeshReferences); + } List? failures = null; try diff --git a/tests/AcDream.App.Tests/Rendering/PrivateEntityViewportRendererResidencyTests.cs b/tests/AcDream.App.Tests/Rendering/PrivateEntityViewportRendererResidencyTests.cs new file mode 100644 index 00000000..afc837e1 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/PrivateEntityViewportRendererResidencyTests.cs @@ -0,0 +1,225 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Wb; +using AcDream.Core.World; + +namespace AcDream.App.Tests.Rendering; + +/// +/// #443: private paperdoll/examination entities must cross the mesh-upload +/// barrier before the shared viewport publishes them. These tests exercise +/// the renderer's slot directly, without requiring a GPU device. +/// +public sealed class PrivateEntityViewportRendererResidencyTests +{ + [Fact] + public void FirstEntityIsNotPublishedUntilEveryDrawableMeshIsReady() + { + const ulong first = 0x0100_0001u; + const ulong second = 0x0100_0002u; + var adapter = new RecordingMeshAdapter { ReadyIds = { first } }; + var textures = new RecordingTextureLifetime(); + var slot = CreateSlot(adapter, textures); + WorldEntity entity = Entity(first, second); + + slot.Set(entity); + + Assert.True(slot.HasPending); + Assert.Null(slot.Entity); + Assert.False(slot.PrepareForDraw()); + Assert.Null(slot.Entity); + Assert.Equal(1, adapter.ReferenceCount(first)); + Assert.Equal(1, adapter.ReferenceCount(second)); + + adapter.ReadyIds.Add(second); + + Assert.True(slot.PrepareForDraw()); + Assert.False(slot.HasPending); + Assert.Same(entity, slot.Entity); + + slot.Dispose(); + Assert.Equal(0, adapter.TotalReferences); + Assert.Equal(1, textures.ReleaseCount); + } + + [Fact] + public void ReplacementKeepsActiveEntityUntilCandidateIsReady() + { + const ulong first = 0x0100_0011u; + const ulong second = 0x0100_0012u; + var adapter = new RecordingMeshAdapter { ReadyIds = { first } }; + var textures = new RecordingTextureLifetime(); + var slot = CreateSlot(adapter, textures); + WorldEntity active = Entity(first); + WorldEntity candidate = Entity(second); + slot.Set(active); + Assert.True(slot.PrepareForDraw()); + + slot.Set(candidate); + + Assert.True(slot.HasPending); + Assert.Same(active, slot.Entity); + Assert.False(slot.PrepareForDraw()); + Assert.Same(active, slot.Entity); + Assert.Equal(1, adapter.ReferenceCount(first)); + Assert.Equal(1, adapter.ReferenceCount(second)); + + adapter.ReadyIds.Add(second); + + Assert.True(slot.PrepareForDraw()); + Assert.Same(candidate, slot.Entity); + Assert.Equal(0, adapter.ReferenceCount(first)); + Assert.Equal(1, adapter.ReferenceCount(second)); + Assert.Equal(1, textures.ReleaseCount); + + slot.Dispose(); + Assert.Equal(0, adapter.TotalReferences); + } + + [Fact] + public void InPlaceMeshChangeIsDetectedAtTheDrawBarrier() + { + const ulong first = 0x0100_0021u; + const ulong second = 0x0100_0022u; + var adapter = new RecordingMeshAdapter { ReadyIds = { first } }; + var slot = CreateSlot(adapter, new RecordingTextureLifetime()); + WorldEntity entity = Entity(first); + slot.Set(entity); + Assert.True(slot.PrepareForDraw()); + + // Appraisal synchronization and chargen animation can mutate the same + // retained clone without calling Set again. + entity.MeshRefs = [new MeshRef((uint)second, Matrix4x4.Identity)]; + + Assert.False(slot.PrepareForDraw()); + Assert.True(slot.HasPending); + Assert.Same(entity, slot.Entity); + Assert.Equal(1, adapter.ReferenceCount(first)); + Assert.Equal(1, adapter.ReferenceCount(second)); + + adapter.ReadyIds.Add(second); + + Assert.True(slot.PrepareForDraw()); + Assert.False(slot.HasPending); + Assert.Equal(0, adapter.ReferenceCount(first)); + Assert.Equal(1, adapter.ReferenceCount(second)); + + slot.Dispose(); + } + + [Fact] + public void NewerCandidateReleasesSupersededPendingOwner() + { + const ulong first = 0x0100_0031u; + const ulong second = 0x0100_0032u; + var adapter = new RecordingMeshAdapter(); + var slot = CreateSlot(adapter, new RecordingTextureLifetime()); + + slot.Set(Entity(first)); + slot.Set(Entity(second)); + + Assert.True(slot.HasPending); + Assert.Equal(0, adapter.ReferenceCount(first)); + Assert.Equal(1, adapter.ReferenceCount(second)); + + slot.Dispose(); + Assert.Equal(0, adapter.TotalReferences); + } + + [Fact] + public void UnresolvedPartOverrideDoesNotBlockResolvedDrawableMeshes() + { + const ulong drawable = 0x0100_0041u; + const ulong overrideId = 0x0100_0042u; + var adapter = new RecordingMeshAdapter { ReadyIds = { drawable } }; + var slot = CreateSlot(adapter, new RecordingTextureLifetime()); + WorldEntity entity = Entity( + [drawable], + [new PartOverride(3, (uint)overrideId)]); + + slot.Set(entity); + + Assert.True(slot.PrepareForDraw()); + Assert.Same(entity, slot.Entity); + Assert.Equal(1, adapter.ReferenceCount(overrideId)); + + slot.Dispose(); + Assert.Equal(0, adapter.TotalReferences); + } + + [Fact] + public void ClearReleasesBothActiveAndPendingOwners() + { + const ulong first = 0x0100_0051u; + const ulong second = 0x0100_0052u; + var adapter = new RecordingMeshAdapter { ReadyIds = { first } }; + var textures = new RecordingTextureLifetime(); + var slot = CreateSlot(adapter, textures); + slot.Set(Entity(first)); + Assert.True(slot.PrepareForDraw()); + slot.Set(Entity(second)); + + slot.Set(null); + + Assert.Null(slot.Entity); + Assert.False(slot.HasPending); + Assert.Equal(0, adapter.TotalReferences); + Assert.Equal(1, textures.ReleaseCount); + + slot.Dispose(); + Assert.Equal(1, textures.ReleaseCount); + } + + private static PrivateEntityViewportRenderer.EntitySlot CreateSlot( + IWbMeshAdapter adapter, + IEntityTextureLifetime textures) => + new(adapter, textures, ownerLocalId: 0xDA11_D012u, "#443 test viewport"); + + private static WorldEntity Entity(params ulong[] meshIds) => + Entity(meshIds, []); + + private static WorldEntity Entity( + IReadOnlyList meshIds, + IReadOnlyList partOverrides) => new() + { + Id = 0xDA11_D012u, + ServerGuid = 0xDA11_D011u, + SourceGfxObjOrSetupId = 0x0200_0001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = meshIds + .Select(static id => new MeshRef((uint)id, Matrix4x4.Identity)) + .ToArray(), + PartOverrides = partOverrides, + }; + + private sealed class RecordingTextureLifetime : IEntityTextureLifetime + { + public int ReleaseCount { get; private set; } + + public void ReleaseOwner(uint localEntityId) => ReleaseCount++; + } + + private sealed class RecordingMeshAdapter : IWbMeshAdapter + { + private readonly Dictionary _references = []; + + public HashSet ReadyIds { get; } = []; + public int TotalReferences => _references.Values.Sum(); + + public int ReferenceCount(ulong id) => _references.GetValueOrDefault(id); + + public bool IsRenderDataReady(ulong id) => ReadyIds.Contains(id); + + public void IncrementRefCount(ulong id) => + _references[id] = ReferenceCount(id) + 1; + + public void DecrementRefCount(ulong id) + { + int current = ReferenceCount(id); + if (current <= 0) + throw new InvalidOperationException("reference underflow"); + _references[id] = current - 1; + } + } +} From af9327a17b81a95d89c6c6f86f3b37b856104cd8 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 19:17:13 +0200 Subject: [PATCH 79/89] feat(launcher): stabilize prepared content updates --- docs/architecture/acdream-architecture.md | 31 +- docs/architecture/worldbuilder-inventory.md | 14 + ...26-08-25-launcher-content-stabilization.md | 230 ++++++ .../ContentEffectsAudioComposition.cs | 58 +- .../Configuration/SessionConfiguration.cs | 6 + .../SessionConfigurationLoader.cs | 12 + src/AcDream.App/Rendering/GameWindow.cs | 3 + src/AcDream.App/RuntimeOptions.cs | 12 + src/AcDream.Content/IPreparedAssetSource.cs | 12 +- .../LayeredPreparedAssetSource.cs | 239 ++++++ .../Configuration/HeadlessConfiguration.cs | 6 + .../HeadlessConfigurationLoader.cs | 12 + .../Hosting/HeadlessProcessContentOwner.cs | 49 +- .../Installation/BakeProcessRunner.cs | 47 +- .../Installation/ContentMigrationCatalog.cs | 127 ++++ .../Installation/LauncherContentStateStore.cs | 413 +++++++++++ .../LauncherInstallRecordStore.cs | 109 ++- .../Installation/LauncherInstaller.cs | 692 +++++++++++++++++- .../Launching/LauncherInstallRecord.cs | 27 + .../Launching/SessionConfigComposer.cs | 31 + .../Launching/SessionConfigDocument.cs | 9 + .../Orchestration/ILauncherOrchestrator.cs | 11 + .../Orchestration/LauncherOrchestrator.cs | 16 +- src/AcDream.Launcher/App.axaml.cs | 41 +- .../LauncherUpdateComposition.cs | 12 +- src/AcDream.Launcher/MainWindow.axaml | 7 +- .../ViewModels/FirstRunInstallerViewModel.cs | 136 +++- .../ViewModels/LauncherUpdateViewModel.cs | 40 +- .../ViewModels/LauncherWindowViewModel.cs | 240 +++++- .../ContentEffectsAudioCompositionTests.cs | 6 + .../RuntimeOptionsSessionConfigTests.cs | 53 ++ .../LayeredPreparedAssetSourceTests.cs | 209 ++++++ .../SessionConfigurationSharedFixtureTests.cs | 21 + .../Installation/BakeProcessRunnerTests.cs | 23 + .../LauncherContentStateStoreTests.cs | 156 ++++ .../LauncherInstallRecordStoreTests.cs | 10 +- .../Installation/LauncherInstallerTests.cs | 75 +- .../LauncherOverlayInstallerTests.cs | 218 ++++++ .../PreparedAssetVerificationCacheTests.cs | 20 + .../Launching/SessionConfigComposerTests.cs | 28 + .../LauncherUpdateViewModelTests.cs | 21 + .../LauncherWindowViewModelTests.cs | 371 +++++++++- 42 files changed, 3706 insertions(+), 147 deletions(-) create mode 100644 docs/plans/2026-08-25-launcher-content-stabilization.md create mode 100644 src/AcDream.Content/LayeredPreparedAssetSource.cs create mode 100644 src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs create mode 100644 src/AcDream.Launcher.Core/Installation/LauncherContentStateStore.cs create mode 100644 tests/AcDream.Content.Tests/LayeredPreparedAssetSourceTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Installation/LauncherContentStateStoreTests.cs create mode 100644 tests/AcDream.Launcher.Core.Tests/Installation/LauncherOverlayInstallerTests.cs diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 184e26e4..9487b18b 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -332,7 +332,19 @@ src/ Installation/ -> portable four-DAT validation, Windows retail path discovery, versioned JSONL bake-process orchestration, and atomic SHA/size/tool-version - install-record verification and recovery; one + install-record verification and recovery; + startup discovery begins only after the desktop + window opens, and exceptional whole-pak hashing + reports its long-read status in that window; + content recipes resolve through one compiled + None/Overlay/FullRebuild/Verify migration ledger; + bounded changes build one cumulative filtered + overlay and publish `pak/content.current.json`, + while full rebuilds bake beside the live base and + swap only after candidate verification; a tiny + `pak/content.client-pending` gate survives a + crash/restart until the active client is + confirmed compatible; one OS-handle lease serializes recovery/install per DataDirectory; a second OS-held publication lock plus durable per-transaction nonce makes @@ -362,7 +374,9 @@ src/ is never persisted, and permits HTTP only for a loopback fixture; production remains pinned HTTPS ViewModels/ -> thin MVVM projection over Launcher.Core, - including the first-run DAT/bake wizard and + including the first-run DAT/bake wizard, explicit + world-data work confirmation (kind, reason, + free-space guidance, progress/cancellation), and nonfatal startup/manual update state, actions, progress, cancellation, rollback, and errors -> references Launcher.Core only (Platform transitively); it never owns @@ -372,6 +386,19 @@ src/ -> Linux launcher/probe/headless flows remain portable; graphical-client actions are explicitly disabled until Modern Runtime Slice L resumes + Prepared-content launch contract + -> `install.json` remains the strict backward-compatible base-pak authority + -> optional `pak/content.current.json` binds one cumulative overlay to the + base SHA; there is never an unbounded overlay chain + -> `pak/content.client-pending` prevents newly migrated content from + becoming launchable before the matching client check/install succeeds, + including across launcher restart + -> launcher session config carries base + optional overlay paths and both + recipe identities only for layered launches + -> App and Headless construct one `LayeredPreparedAssetSource`; overlay + Missing falls through to base, while overlay Corrupt is authoritative + for both render and collision reads + AcDream.Headless/ Linux/Windows no-window production host Program.cs -> CLI entry only Configuration/ -> strict versioned process/session config diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index a449b22b..f72fc1ad 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -132,6 +132,20 @@ bake/equivalence/UI-Studio tools, not a production fallback. Portal → HighRes installed-DAT gates are recorded in `docs/research/2026-07-24-slice-c-prepared-asset-cutover-report.md`. +**Launcher cumulative-overlay extension (2026-08-25).** Production still has +no live-DAT fallback and consumes the same prepared-payload contracts. For a +bounded recipe migration, App and Headless may receive one complete base pak +plus one cumulative filtered pak through `LayeredPreparedAssetSource`. The +overlay is probed first: Missing falls through to the base, while a present but +corrupt render or collision payload remains authoritative corruption. Both +mapped owners share one composite lifetime and there is never an overlay +chain. The launcher binds the overlay to the base digest in the optional +`pak/content.current.json` sidecar; format/global extraction migrations retain +the explicit full-rebuild path. A tiny `pak/content.client-pending` marker +keeps either result non-launchable until the matching client is confirmed, +including across a crash/restart. Design and gates: +`docs/plans/2026-08-25-launcher-content-stabilization.md`. + **Slice I3 prepared collision extension (2026-07-25).** The package remains format 1 and retains mesh type values 1–3; bake-tool 4 appends typed GfxObj, Setup, CellStruct, and EnvCell-topology collision payloads. Core owns the diff --git a/docs/plans/2026-08-25-launcher-content-stabilization.md b/docs/plans/2026-08-25-launcher-content-stabilization.md new file mode 100644 index 00000000..ada1a92a --- /dev/null +++ b/docs/plans/2026-08-25-launcher-content-stabilization.md @@ -0,0 +1,230 @@ +# Launcher content stabilization + +**Date:** 2026-08-25 +**Status:** IMPLEMENTED +**Goal:** make prepared-content updates fast, explicit, and safe without +turning the launcher into a package manager. + +## User contract + +1. The launcher window appears before network access, full-file hashing, + baking, recovery, or any other potentially long operation. +2. Ordinary startup reads only small metadata: the install record, pak header, + file length/write time, and the verification sidecar when present. +3. No long content operation begins silently. The launcher first names the + reason, work kind, approximate disk requirement, and whether the existing + installed game remains usable. +4. A prepared-content change uses a small locally generated overlay whenever + the affected DAT IDs/landblocks are bounded. A full rebuild is an explicit, + rare fallback for format changes or extraction changes with unbounded + impact. +5. The launcher never starts a mixed client/content pair. Cancellation or a + bake/publication failure preserves the prior pair. Once approved content is + ready, an unavailable or failed matching-client update leaves Play disabled + and retains the verified content for a cheap retry. + +## What exists already + +- `acdream.pak` has a 64-byte header containing DAT iterations, format version, + and `BakeToolVersion` (the current content-recipe identity). +- `install.json` records the pak SHA-256, size, DAT path, and recipe identity. +- `install.verification.json` avoids the former 24-second startup hash when + size/write-time still match. Explicit **Verify files** remains the full-hash + path. +- `acdream-bake` already accepts `--ids` and `--landblocks`, and a filtered + bake produces an ordinary valid pak with only those typed keys. +- `IPreparedAssetSource` and `IPreparedCollisionSource` are the existing + renderer/physics seams; no consumer needs to know which mapped pak supplied a + key. +- Launcher and client are published together, and the launcher payload already + includes the matching bake executable. + +## Deliberately small model + +There are only four work kinds: + +| Kind | Launcher behavior | +|---|---| +| `None` | No content prompt. | +| `Overlay` | Build one cumulative overlay containing all keys changed since the base recipe. | +| `FullRebuild` | Explain the long rebuild and required free space before starting. | +| `Verify` | User-requested or exceptional recovery hash; always visible and cancellable. | + +The release-feed schema remains unchanged for the first implementation. Every +published build already updates the launcher before the client. The updated +launcher carries the matching content requirement and a small compiled +migration catalog. This avoids stranding strict schema-1 launchers on a feed +shape they cannot parse. A future independently versioned content feed can +replace the catalog without changing the runtime content model. + +`BakeToolVersion` is retained on disk for compatibility but is treated as a +**content recipe version**, not an executable build number. It changes only +when the produced prepared content changes. + +## On-disk content state + +The existing `install.json` remains the base-pak authority and is not extended; +older launchers reject unknown fields. New state lives in the optional sidecar +`DataDirectory/pak/content.current.json`: + +```json +{ + "schemaVersion": 1, + "baseSha256": "", + "effectiveRecipeVersion": 6, + "overlay": { + "path": "acdream-update-6.pak", + "sha256": "<64 lowercase hex>", + "size": 123, + "recipeVersion": 6 + } +} +``` + +Rules: + +- The sidecar is valid only when `baseSha256` binds it to the current base + record and every path is a safe canonical filename beneath the pak directory. +- At most one overlay is active. A later overlay is cumulative and atomically + replaces the prior sidecar; there is no unbounded lookup chain. +- The base and overlay must name the same installed DAT iterations and pak + format. The base may carry an older recipe; the overlay carries the effective + recipe. +- Missing overlay keys fall through to the base. A present-but-corrupt overlay + key is authoritative corruption and never falls through. +- Render and collision reads follow the same ordering and share the same two + memory mappings. +- An absent sidecar means the base pak is the complete active content set. +- `content.client-pending` is a separate, tiny crash-safe activation gate. A + content migration creates it before touching content and removes it only + after client compatibility is confirmed. It deliberately carries no package + graph; existence means “do not publish this content to Play yet.” + +## Migration catalog + +One compiled catalog entry describes each recipe transition: + +```text +target recipe +work kind +player-facing reason +affected DAT IDs and/or landblocks (overlay only) +``` + +To update a base from recipe 5 directly to recipe 7, the launcher asks the +catalog for the cumulative 5 -> 7 impact and emits one recipe-7 overlay. If any +step is `FullRebuild`, the combined migration is a full rebuild. A missing +catalog step fails closed with an explanatory error; it never guesses. + +The recent procedural night-sky change is `None` because it changed client +shader/code only. A future addition of bounded prepared sky keys can be +`Overlay`. A global mesh-extraction correction such as recipe 5's solid-face +change is `FullRebuild`. + +## Update transaction and UI + +The launcher keeps the existing one-question update surface. When the candidate +client needs newer content, pressing **Update** first opens the content-work +confirmation: + +> **World data update required** +> This release adds prepared sky assets. acdream will build a small update +> from your installed Asheron's Call files. The existing game stays installed +> until this finishes. +> Estimated work: overlay / approximately N files / M free space required. +> **Update now** · **Later** + +After confirmation: + +1. Validate the remembered DAT directory and free-space floor. +2. Build to a transaction-owned candidate path while the active content stays + untouched. +3. Validate pak header/TOC and compute the new artifact's SHA once. Never hash + the unchanged base as part of an overlay update. +4. Atomically publish the content sidecar. +5. Install/activate the compatible client. + +The newly prepared content is not published to the launch orchestrator until +the startup check confirms that the active client is compatible or the client +update succeeds. Choosing **Not now**, losing the network, or failing the +client download therefore cannot launch the old executable against the new +pak. The launcher keeps the verified content on disk and resumes at the much +smaller client-update step. + +For `FullRebuild`, the same transaction builds a candidate base beside the old +base, verifies it, then atomically swaps the base record/file. It never moves +the playable base out of place before the long build starts. + +Progress uses the existing strict Bake JSONL protocol and shows phase, +percentage, failures, and ETA. Cancellation returns to the launcher without +changing active content. + +## Startup ordering + +`App.OnFrameworkInitializationCompleted` must not synchronously wait on +`LoadExistingAsync` before constructing `MainWindow`. It constructs the shell +with an explicit `Checking` installation state, assigns/shows the window, then +starts content discovery on the UI dispatcher. Feed update checking begins only +after that cheap discovery completes, preventing two startup modals from +racing. + +If an exceptional recovery path really needs a full base hash, the shell is +already visible and says exactly what it is doing. Launch stays disabled until +the recovery check finishes, but the application never looks frozen. + +## Compatibility and rollback + +- A client session receives the resolved base path plus zero or one overlay + path. Old clients continue receiving only the base. +- The client validates the effective recipe before constructing world owners. +- The updater does not activate a client whose content requirement is + unsatisfied. +- Choosing **Later** leaves the old client/base pair active. +- Client rollback is allowed only when the selected client accepts the active + content set; otherwise the launcher explains the required content rollback + or rebuild instead of launching an incompatible pair. + +## Verification gates + +- Launcher window construction test proves no installer/hash task is awaited + before the main window is assigned. +- Quick-discovery tests cover missing sidecar, matching sidecar, missing cache, + changed length/time, recipe mismatch, and exceptional visible verification. +- Content-state tests cover path containment, base-digest binding, atomic + publication, cancellation, and crash residue. +- Composite-source tests cover overlay hit, base fallback, authoritative + overlay corruption, render/collision parity, stats, and balanced disposal. +- Session-config round trips cover base-only and base+overlay on App and + Headless. +- Update tests prove prepared content cannot become launchable before client + compatibility is confirmed; **Not now** and client-download failure remain + fail-closed, while bake/candidate failure preserves the prior pair. +- Release solution compilation and the affected Launcher, Content, App, and + Headless gates remain green. + +## Implementation checkpoint + +Implemented 2026-08-25: + +- The Avalonia window is assigned and opened before content discovery, client + recovery, feed access, or exceptional hashing begins. +- Ordinary current-install discovery uses metadata/header/cache checks; the + explicit verification command owns visible whole-pak hashing. +- Recipe migrations are compiled and cumulative. Bounded migrations build one + filtered overlay; unbounded/global migrations use the explicit candidate + full-rebuild path. The current recipe 4 -> 5 transition is correctly a full + rebuild because the solid-face extraction change is global. +- Base/overlay reads are unified for render and collision with overlay-first, + Missing-only fallback and authoritative corruption. +- Content activation is bound to client compatibility in memory and through + `content.client-pending`, so **Not now**, failed download, process crash, and + launcher restart cannot expose a mixed pair. + +Final Release gates: + +- `dotnet build AcDream.slnx -c Release`: 0 warnings, 0 errors. +- Launcher UI/ViewModels, excluding the documented manual desktop lane: 82/82. +- Launcher.Core Windows-compatible suite: 360/360. +- Hermetic Content suite: 130/130. +- Affected App layered/session composition: 35/35. +- Affected Headless configuration: 9/9. diff --git a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs index 5b6e72a3..ae4e4d58 100644 --- a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs +++ b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs @@ -51,6 +51,9 @@ internal sealed record ContentEffectsAudioResult( internal sealed record ContentEffectsAudioDependencies( string DatDirectory, string PreparedAssetPath, + string? PreparedAssetOverlayPath, + uint? PreparedAssetBaseRecipeVersion, + uint? PreparedAssetEffectiveRecipeVersion, ResidencyBudgetOptions ResidencyBudgets, PhysicsDataCache PhysicsDataCache, bool DumpMotionEnabled, @@ -99,6 +102,9 @@ internal interface IContentEffectsAudioCompositionFactory IDatReaderWriter OpenDatCollection(string datDirectory); IPreparedAssetSource OpenPreparedAssetSource( string path, + string? overlayPath, + uint? baseRecipeVersion, + uint? effectiveRecipeVersion, IDatReaderWriter dats, Action diagnostic); MagicCatalog LoadMagicCatalog(IDatReaderWriter dats); @@ -169,9 +175,54 @@ internal sealed class RetailContentEffectsAudioCompositionFactory public IPreparedAssetSource OpenPreparedAssetSource( string path, + string? overlayPath, + uint? baseRecipeVersion, + uint? effectiveRecipeVersion, IDatReaderWriter dats, - Action diagnostic) => - new PakPreparedAssetSource(path, dats, diagnostic); + Action diagnostic) + { + if (string.IsNullOrWhiteSpace(overlayPath)) + { + return new PakPreparedAssetSource(path, dats, diagnostic); + } + + if (baseRecipeVersion is not > 0 + || effectiveRecipeVersion is not > 0) + { + throw new InvalidDataException( + "Layered prepared content is missing its recipe identities."); + } + + if (effectiveRecipeVersion + != AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion) + { + throw new InvalidDataException( + $"Prepared content recipe {effectiveRecipeVersion} does not " + + $"match client recipe " + + $"{AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion}."); + } + + PakPreparedAssetSource? baseSource = null; + PakPreparedAssetSource? overlaySource = null; + try + { + baseSource = new PakPreparedAssetSource( + path, + PreparedAssetCatalogIdentity.From(dats, baseRecipeVersion.Value), + diagnostic); + overlaySource = new PakPreparedAssetSource( + overlayPath, + PreparedAssetCatalogIdentity.From(dats, effectiveRecipeVersion.Value), + diagnostic); + return new LayeredPreparedAssetSource(baseSource, overlaySource); + } + catch + { + overlaySource?.Dispose(); + baseSource?.Dispose(); + throw; + } + } public MagicCatalog LoadMagicCatalog(IDatReaderWriter dats) => MagicCatalog.Load(dats); @@ -370,6 +421,9 @@ internal sealed class ContentEffectsAudioCompositionPhase : "prepared asset source", () => _factory.OpenPreparedAssetSource( _dependencies.PreparedAssetPath, + _dependencies.PreparedAssetOverlayPath, + _dependencies.PreparedAssetBaseRecipeVersion, + _dependencies.PreparedAssetEffectiveRecipeVersion, dats, _dependencies.Error), static value => value.Dispose()).Publish( diff --git a/src/AcDream.App/Configuration/SessionConfiguration.cs b/src/AcDream.App/Configuration/SessionConfiguration.cs index ec225785..8e55c922 100644 --- a/src/AcDream.App/Configuration/SessionConfiguration.cs +++ b/src/AcDream.App/Configuration/SessionConfiguration.cs @@ -71,6 +71,12 @@ internal sealed class SessionContentDescriptor [JsonRequired] public string PreparedAssetPath { get; init; } = string.Empty; + + public string? PreparedAssetOverlayPath { get; init; } + + public uint? PreparedAssetBaseRecipeVersion { get; init; } + + public uint? PreparedAssetEffectiveRecipeVersion { get; init; } } internal sealed record SessionDescriptor diff --git a/src/AcDream.App/Configuration/SessionConfigurationLoader.cs b/src/AcDream.App/Configuration/SessionConfigurationLoader.cs index 086cb11e..eaab04a3 100644 --- a/src/AcDream.App/Configuration/SessionConfigurationLoader.cs +++ b/src/AcDream.App/Configuration/SessionConfigurationLoader.cs @@ -83,6 +83,18 @@ internal static class SessionConfigurationLoader throw new SessionConfigurationException( "process.content requires non-empty datDirectory and preparedAssetPath."); } + + bool hasOverlay = !string.IsNullOrWhiteSpace( + content.PreparedAssetOverlayPath); + bool hasBaseRecipe = content.PreparedAssetBaseRecipeVersion is > 0; + bool hasEffectiveRecipe = + content.PreparedAssetEffectiveRecipeVersion is > 0; + if (hasOverlay != hasBaseRecipe || hasOverlay != hasEffectiveRecipe) + { + throw new SessionConfigurationException( + "process.content overlay path, base recipe, and effective recipe " + + "must be supplied together."); + } } private static void ValidateSession(SessionDescriptor session) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b0c4b52f..11ef0f3a 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1418,6 +1418,9 @@ public sealed class GameWindow : new ContentEffectsAudioDependencies( _datDir, _options.PreparedAssetPath, + _options.PreparedAssetOverlayPath, + _options.PreparedAssetBaseRecipeVersion, + _options.PreparedAssetEffectiveRecipeVersion, _options.ResidencyBudgets, _physicsDataCache, _animationDiagnostics.DumpMotionEnabled, diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 22e6679a..53fb6a53 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -116,6 +116,12 @@ public sealed record RuntimeOptions( /// , milliseconds. int LoginCommandDelayMs) { + public string? PreparedAssetOverlayPath { get; init; } + + public uint? PreparedAssetBaseRecipeVersion { get; init; } + + public uint? PreparedAssetEffectiveRecipeVersion { get; init; } + /// /// Build options from the process environment. Used by /// Program.cs at startup. @@ -272,6 +278,12 @@ public sealed record RuntimeOptions( { PreparedAssetPath = NullIfEmpty(content?.PreparedAssetPath) ?? baseOptions.PreparedAssetPath, + PreparedAssetOverlayPath = + NullIfEmpty(content?.PreparedAssetOverlayPath), + PreparedAssetBaseRecipeVersion = + content?.PreparedAssetBaseRecipeVersion, + PreparedAssetEffectiveRecipeVersion = + content?.PreparedAssetEffectiveRecipeVersion, LiveMode = true, // Campaign LA gate round 2: a session-config launch IS a product // launch — the retail UI is the shipped UI, not a dev option. diff --git a/src/AcDream.Content/IPreparedAssetSource.cs b/src/AcDream.Content/IPreparedAssetSource.cs index f9f58659..468e9857 100644 --- a/src/AcDream.Content/IPreparedAssetSource.cs +++ b/src/AcDream.Content/IPreparedAssetSource.cs @@ -93,14 +93,24 @@ public readonly record struct PreparedAssetCatalogIdentity( uint BakeToolVersion) { public static PreparedAssetCatalogIdentity From(IDatReaderWriter dats) + => From(dats, PakFormat.CurrentBakeToolVersion); + + public static PreparedAssetCatalogIdentity From( + IDatReaderWriter dats, + uint bakeToolVersion) { ArgumentNullException.ThrowIfNull(dats); + if (bakeToolVersion == 0) + { + throw new ArgumentOutOfRangeException(nameof(bakeToolVersion)); + } + return new( checked((uint)dats.PortalIteration), checked((uint)dats.CellIteration), checked((uint)dats.HighResIteration), checked((uint)dats.LanguageIteration), - PakFormat.CurrentBakeToolVersion); + bakeToolVersion); } } diff --git a/src/AcDream.Content/LayeredPreparedAssetSource.cs b/src/AcDream.Content/LayeredPreparedAssetSource.cs new file mode 100644 index 00000000..1192c76a --- /dev/null +++ b/src/AcDream.Content/LayeredPreparedAssetSource.cs @@ -0,0 +1,239 @@ +using AcDream.Content.Pak; +using AcDream.Core.Physics; + +namespace AcDream.Content; + +/// +/// One cumulative overlay in front of one complete base package. Missing keys +/// fall through; an overlay key that exists but is corrupt is authoritative and +/// never hides its corruption behind older base bytes. Render and collision +/// payloads use the exact same rule and the two package owners are disposed as +/// one content set. +/// +public sealed class LayeredPreparedAssetSource : + IPreparedAssetSource, + IPreparedCollisionSource +{ + private IPreparedAssetSource? _baseAssets; + private IPreparedAssetSource? _overlayAssets; + private IPreparedCollisionSource? _baseCollision; + private IPreparedCollisionSource? _overlayCollision; + + public LayeredPreparedAssetSource( + IPreparedAssetSource baseSource, + IPreparedAssetSource overlaySource) + { + ArgumentNullException.ThrowIfNull(baseSource); + ArgumentNullException.ThrowIfNull(overlaySource); + if (ReferenceEquals(baseSource, overlaySource)) + { + throw new ArgumentException( + "The base and overlay must have independent owners.", + nameof(overlaySource)); + } + + _baseCollision = baseSource as IPreparedCollisionSource + ?? throw new ArgumentException( + "The base source must expose prepared collision payloads.", + nameof(baseSource)); + _overlayCollision = overlaySource as IPreparedCollisionSource + ?? throw new ArgumentException( + "The overlay source must expose prepared collision payloads.", + nameof(overlaySource)); + _baseAssets = baseSource; + _overlayAssets = overlaySource; + } + + public PreparedAssetSourceStats Stats + { + get + { + IPreparedAssetSource baseSource = Require(_baseAssets); + IPreparedAssetSource overlay = Require(_overlayAssets); + PreparedAssetSourceStats left = baseSource.Stats; + PreparedAssetSourceStats right = overlay.Stats; + return new( + left.Probes + right.Probes, + left.Reads + right.Reads, + left.Loaded + right.Loaded, + left.Missing + right.Missing, + left.Corrupt + right.Corrupt); + } + } + + public PreparedCollisionSourceStats CollisionStats + { + get + { + IPreparedCollisionSource baseSource = Require(_baseCollision); + IPreparedCollisionSource overlay = Require(_overlayCollision); + PreparedCollisionSourceStats left = baseSource.CollisionStats; + PreparedCollisionSourceStats right = overlay.CollisionStats; + return new( + left.Probes + right.Probes, + left.Reads + right.Reads, + left.Loaded + right.Loaded, + left.Missing + right.Missing, + left.Corrupt + right.Corrupt); + } + } + + public CacheStats DecodedTextureCacheStats + { + get + { + CacheStats left = Require(_baseAssets).DecodedTextureCacheStats; + CacheStats right = Require(_overlayAssets).DecodedTextureCacheStats; + return new( + left.Hits + right.Hits, + left.Misses + right.Misses, + left.Evictions + right.Evictions); + } + } + + public long MappedVirtualBytes => + checked( + Require(_baseAssets).MappedVirtualBytes + + Require(_overlayAssets).MappedVirtualBytes); + + public PreparedAssetPresence Probe(PakAssetType type, uint sourceFileId) + { + PreparedAssetPresence overlay = + Require(_overlayAssets).Probe(type, sourceFileId); + return overlay == PreparedAssetPresence.Missing + ? Require(_baseAssets).Probe(type, sourceFileId) + : overlay; + } + + public PreparedAssetReadResult Read( + in PreparedAssetRequest request, + CancellationToken cancellationToken = default) + { + PreparedAssetReadResult overlay = + Require(_overlayAssets).Read(request, cancellationToken); + return overlay.Status == PreparedAssetReadStatus.Missing + ? Require(_baseAssets).Read(request, cancellationToken) + : overlay; + } + + public PreparedAssetPresence ProbeCollision( + PakAssetType type, + uint sourceFileId) + { + PreparedAssetPresence overlay = + Require(_overlayCollision).ProbeCollision(type, sourceFileId); + return overlay == PreparedAssetPresence.Missing + ? Require(_baseCollision).ProbeCollision(type, sourceFileId) + : overlay; + } + + public PreparedCollisionReadResult + ReadGfxObjCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) + { + PreparedCollisionReadResult overlay = + Require(_overlayCollision).ReadGfxObjCollision( + sourceFileId, + cancellationToken); + return overlay.Status == PreparedAssetReadStatus.Missing + ? Require(_baseCollision).ReadGfxObjCollision( + sourceFileId, + cancellationToken) + : overlay; + } + + public PreparedCollisionReadResult + ReadSetupCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) + { + PreparedCollisionReadResult overlay = + Require(_overlayCollision).ReadSetupCollision( + sourceFileId, + cancellationToken); + return overlay.Status == PreparedAssetReadStatus.Missing + ? Require(_baseCollision).ReadSetupCollision( + sourceFileId, + cancellationToken) + : overlay; + } + + public PreparedCollisionReadResult + ReadCellStructureCollision( + uint sourceFileId, + CancellationToken cancellationToken = default) + { + PreparedCollisionReadResult overlay = + Require(_overlayCollision).ReadCellStructureCollision( + sourceFileId, + cancellationToken); + return overlay.Status == PreparedAssetReadStatus.Missing + ? Require(_baseCollision).ReadCellStructureCollision( + sourceFileId, + cancellationToken) + : overlay; + } + + public PreparedCollisionReadResult + ReadEnvCellTopology( + uint sourceFileId, + CancellationToken cancellationToken = default) + { + PreparedCollisionReadResult overlay = + Require(_overlayCollision).ReadEnvCellTopology( + sourceFileId, + cancellationToken); + return overlay.Status == PreparedAssetReadStatus.Missing + ? Require(_baseCollision).ReadEnvCellTopology( + sourceFileId, + cancellationToken) + : overlay; + } + + public void Dispose() + { + IPreparedAssetSource? overlay = Interlocked.Exchange( + ref _overlayAssets, + null); + IPreparedAssetSource? baseSource = Interlocked.Exchange( + ref _baseAssets, + null); + _overlayCollision = null; + _baseCollision = null; + + List? failures = null; + DisposeOne(overlay, ref failures); + DisposeOne(baseSource, ref failures); + if (failures is { Count: > 0 }) + { + throw new AggregateException( + "One or more prepared-content layers failed to dispose.", + failures); + } + } + + private static T Require(T? value) + where T : class => + value ?? throw new ObjectDisposedException( + nameof(LayeredPreparedAssetSource)); + + private static void DisposeOne( + IDisposable? value, + ref List? failures) + { + if (value is null) + { + return; + } + + try + { + value.Dispose(); + } + catch (Exception exception) + { + (failures ??= []).Add(exception); + } + } +} diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs index 6bf5f0e1..76e2eacf 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs @@ -27,6 +27,12 @@ internal sealed class HeadlessContentDescriptor [JsonRequired] public string PreparedAssetPath { get; init; } = string.Empty; + + public string? PreparedAssetOverlayPath { get; init; } + + public uint? PreparedAssetBaseRecipeVersion { get; init; } + + public uint? PreparedAssetEffectiveRecipeVersion { get; init; } } // MF-1 (Campaign OP OP7 review fix, 2026-08-11): record, not class — the diff --git a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs index 389b2a6f..d6fb73d8 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs @@ -173,6 +173,18 @@ internal static class HeadlessConfigurationLoader throw new HeadlessConfigurationException( "process.content requires non-empty datDirectory and preparedAssetPath."); } + + bool hasOverlay = !string.IsNullOrWhiteSpace( + content.PreparedAssetOverlayPath); + bool hasBaseRecipe = content.PreparedAssetBaseRecipeVersion is > 0; + bool hasEffectiveRecipe = + content.PreparedAssetEffectiveRecipeVersion is > 0; + if (hasOverlay != hasBaseRecipe || hasOverlay != hasEffectiveRecipe) + { + throw new HeadlessConfigurationException( + "process.content overlay path, base recipe, and effective recipe " + + "must be supplied together."); + } } private static void ValidateSession( diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs b/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs index 5c42ea75..45286e52 100644 --- a/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs +++ b/src/AcDream.Headless/Hosting/HeadlessProcessContentOwner.cs @@ -43,15 +43,56 @@ internal sealed class ProductionHeadlessProcessContentFactory string datDirectory = Path.GetFullPath(descriptor.DatDirectory); string preparedAssetPath = Path.GetFullPath(descriptor.PreparedAssetPath); + string? overlayPath = string.IsNullOrWhiteSpace( + descriptor.PreparedAssetOverlayPath) + ? null + : Path.GetFullPath(descriptor.PreparedAssetOverlayPath); IDatReaderWriter? dats = null; IPreparedAssetSource? prepared = null; try { dats = RuntimeDatCollectionFactory.OpenReadOnly(datDirectory); - prepared = new PakPreparedAssetSource( - preparedAssetPath, - dats, - diagnostic); + if (overlayPath is null) + { + prepared = new PakPreparedAssetSource( + preparedAssetPath, + dats, + diagnostic); + } + else + { + if (descriptor.PreparedAssetBaseRecipeVersion is not > 0 + || descriptor.PreparedAssetEffectiveRecipeVersion + != AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion) + { + throw new InvalidDataException( + "Layered prepared content does not match the client's recipe."); + } + + var baseSource = new PakPreparedAssetSource( + preparedAssetPath, + PreparedAssetCatalogIdentity.From( + dats, + descriptor.PreparedAssetBaseRecipeVersion.Value), + diagnostic); + try + { + var overlaySource = new PakPreparedAssetSource( + overlayPath, + PreparedAssetCatalogIdentity.From( + dats, + descriptor.PreparedAssetEffectiveRecipeVersion.Value), + diagnostic); + prepared = new LayeredPreparedAssetSource( + baseSource, + overlaySource); + } + catch + { + baseSource.Dispose(); + throw; + } + } MagicCatalog magic = MagicCatalog.Load(dats); Region region = dats.Get(0x13000000u) ?? throw new InvalidOperationException( diff --git a/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs b/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs index 24fbc48c..075b34e2 100644 --- a/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs +++ b/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs @@ -11,18 +11,43 @@ public sealed record BakeProcessRequest( string DatDirectory, string OutputPath, int Threads, - string? PublicationNonce = null) + string? PublicationNonce = null, + IReadOnlyList? DatIds = null, + IReadOnlyList? Landblocks = null) { - public IReadOnlyList Arguments => - [ - "--dat-dir", - DatDirectory, - "--out", - OutputPath, - "--threads", - Threads.ToString(CultureInfo.InvariantCulture), - "--progress-json", - ]; + public IReadOnlyList Arguments + { + get + { + var arguments = new List + { + "--dat-dir", + DatDirectory, + "--out", + OutputPath, + "--threads", + Threads.ToString(CultureInfo.InvariantCulture), + "--progress-json", + }; + if (DatIds is { Count: > 0 }) + { + arguments.Add("--ids"); + arguments.Add(string.Join( + ',', + DatIds.Select(static id => $"0x{id:X8}"))); + } + + if (Landblocks is { Count: > 0 }) + { + arguments.Add("--landblocks"); + arguments.Add(string.Join( + ',', + Landblocks.Select(static id => $"0x{id:X2}"))); + } + + return arguments; + } + } } public sealed record BakeProcessResult(int ExitCode, string StandardError); diff --git a/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs b/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs new file mode 100644 index 00000000..500d5fc9 --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/ContentMigrationCatalog.cs @@ -0,0 +1,127 @@ +namespace AcDream.Launcher.Core.Installation; + +/// The only four prepared-content actions exposed to the launcher UI. +public enum ContentWorkKind +{ + None, + Overlay, + FullRebuild, + Verify, +} + +/// +/// One resolved recipe migration. Overlay ids are acdream-bake's existing +/// hexadecimal DAT-id filters; landblocks use its existing 8-bit hexadecimal +/// landblock filter. A plan is deliberately data-only so update orchestration +/// and the UI do not need to understand extraction algorithms. +/// +public sealed record ContentMigrationPlan( + uint FromRecipeVersion, + uint TargetRecipeVersion, + ContentWorkKind Kind, + string Reason, + IReadOnlyList? DatIds = null, + IReadOnlyList? Landblocks = null) +{ + public IReadOnlyList EffectiveDatIds => DatIds ?? []; + + public IReadOnlyList EffectiveLandblocks => Landblocks ?? []; +} + +/// +/// Compiled content-recipe ledger. Launcher and client ship together, so the +/// updated launcher always knows how to prepare the matching client's recipe +/// without changing the strict release-feed schema. +/// +public static class ContentMigrationCatalog +{ + private static readonly IReadOnlyDictionary Steps = + new Dictionary + { + [2] = FullRebuild(1, 2, "prepared EnvCell identity changed"), + [3] = FullRebuild(2, 3, "render-pass translucency moved into prepared meshes"), + [4] = FullRebuild(3, 4, "flat collision and EnvCell topology were added"), + [5] = FullRebuild( + 4, + 5, + "solid-colour positive mesh faces must be regenerated"), + }; + + public static ContentMigrationPlan Resolve(uint fromRecipeVersion, uint targetRecipeVersion) + { + if (fromRecipeVersion == 0 || targetRecipeVersion == 0) + { + throw new ArgumentOutOfRangeException( + nameof(fromRecipeVersion), + "Content recipe versions must be positive."); + } + + if (fromRecipeVersion == targetRecipeVersion) + { + return new ContentMigrationPlan( + fromRecipeVersion, + targetRecipeVersion, + ContentWorkKind.None, + "Prepared content already matches this client."); + } + + if (fromRecipeVersion > targetRecipeVersion) + { + throw new InvalidOperationException( + $"Prepared content recipe {fromRecipeVersion} is newer than this " + + $"launcher's recipe {targetRecipeVersion}."); + } + + var ids = new HashSet(); + var landblocks = new HashSet(); + var reasons = new List(); + ContentWorkKind combinedKind = ContentWorkKind.None; + for (uint target = checked(fromRecipeVersion + 1); + target <= targetRecipeVersion; + target++) + { + if (!Steps.TryGetValue(target, out ContentMigrationPlan? step) + || step.FromRecipeVersion != target - 1) + { + throw new InvalidOperationException( + $"No prepared-content migration is published for recipe " + + $"{target - 1} to {target}."); + } + + reasons.Add(step.Reason); + if (step.Kind == ContentWorkKind.FullRebuild) + { + combinedKind = ContentWorkKind.FullRebuild; + } + else if (combinedKind != ContentWorkKind.FullRebuild + && step.Kind == ContentWorkKind.Overlay) + { + combinedKind = ContentWorkKind.Overlay; + } + + foreach (uint id in step.EffectiveDatIds) + { + ids.Add(id); + } + + foreach (byte landblock in step.EffectiveLandblocks) + { + landblocks.Add(landblock); + } + } + + return new ContentMigrationPlan( + fromRecipeVersion, + targetRecipeVersion, + combinedKind, + string.Join("; ", reasons), + ids.Order().ToArray(), + landblocks.Order().ToArray()); + } + + private static ContentMigrationPlan FullRebuild( + uint from, + uint target, + string reason) => + new(from, target, ContentWorkKind.FullRebuild, reason); +} diff --git a/src/AcDream.Launcher.Core/Installation/LauncherContentStateStore.cs b/src/AcDream.Launcher.Core/Installation/LauncherContentStateStore.cs new file mode 100644 index 00000000..2a14452b --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/LauncherContentStateStore.cs @@ -0,0 +1,413 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using AcDream.Launcher.Core.Integrity; +using AcDream.Launcher.Core.Launching; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Installation; + +public sealed record LauncherContentOverlay( + string Path, + string Sha256, + long Size, + uint RecipeVersion); + +public sealed record LauncherContentState( + int SchemaVersion, + string BaseSha256, + uint EffectiveRecipeVersion, + LauncherContentOverlay? Overlay) +{ + public const int CurrentSchemaVersion = 1; +} + +/// +/// Optional overlay authority kept beside, rather than inside, install.json. +/// Old launchers safely ignore this file instead of rejecting a new field in +/// their strict install-record schema. +/// +public sealed class LauncherContentStateStore +{ + private const uint PakMagic = 0x4B504341u; + private const uint PakFormatVersion = 1; + private const int PakHeaderSize = 64; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + }; + + private readonly string _pakDirectory; + private readonly Func> _computeSha256; + + public LauncherContentStateStore( + ApplicationPathSet paths, + Func>? computeSha256 = null) + { + ArgumentNullException.ThrowIfNull(paths); + _pakDirectory = Path.Combine( + Path.GetFullPath(paths.DataDirectory), + "pak"); + _computeSha256 = computeSha256 + ?? ((path, cancellationToken) => + FileIntegrity.ComputeSha256HexAsync(path, cancellationToken)); + } + + public string StatePath => Path.Combine(_pakDirectory, "content.current.json"); + + public string ClientCompatibilityPendingPath => Path.Combine( + _pakDirectory, + "content.client-pending"); + + public string OverlayCandidatePath => Path.Combine( + _pakDirectory, + ".acdream-update.candidate.pak"); + + public bool IsClientCompatibilityPending => + File.Exists(ClientCompatibilityPendingPath); + + public void MarkClientCompatibilityPending() + { + Directory.CreateDirectory(_pakDirectory); + string temporaryPath = ClientCompatibilityPendingPath + ".tmp"; + File.WriteAllText( + temporaryPath, + LauncherInstallRecordStore.CurrentBakeToolVersion.ToString( + CultureInfo.InvariantCulture)); + File.Move( + temporaryPath, + ClientCompatibilityPendingPath, + overwrite: true); + } + + public void ClearClientCompatibilityPending() + { + LauncherInstallRecordStore.TryDelete(ClientCompatibilityPendingPath); + LauncherInstallRecordStore.TryDelete( + ClientCompatibilityPendingPath + ".tmp"); + } + + public string GetOverlayPath(LauncherContentOverlay overlay) + { + ArgumentNullException.ThrowIfNull(overlay); + string? error = ValidateOverlayFileName(overlay.Path); + if (error is not null) + { + throw new InvalidDataException(error); + } + + return Path.Combine(_pakDirectory, overlay.Path); + } + + public async Task<(LauncherContentState? State, string? Error)> LoadAsync( + LauncherInstallRecord baseRecord, + bool forceFullVerification = false, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(baseRecord); + if (!File.Exists(StatePath)) + { + return (null, null); + } + + LauncherContentState? state; + try + { + await using FileStream stream = new( + StatePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 4096, + FileOptions.Asynchronous | FileOptions.SequentialScan); + state = await JsonSerializer.DeserializeAsync( + stream, + SerializerOptions, + cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or JsonException + or NotSupportedException) + { + return (null, $"The prepared-content update record could not be read: {ex.Message}"); + } + + string? contractError = ValidateContract(baseRecord, state); + if (contractError is not null) + { + return (null, contractError); + } + + LauncherContentOverlay overlay = state!.Overlay!; + string overlayPath = GetOverlayPath(overlay); + if (!File.Exists(baseRecord.PreparedAssetPath)) + { + return (null, "The base prepared package is missing."); + } + + if (new FileInfo(baseRecord.PreparedAssetPath).Length + != baseRecord.PreparedAssetSize) + { + return (null, "The base prepared package size changed."); + } + + if (!File.Exists(overlayPath)) + { + return (null, "The prepared-content overlay is missing."); + } + + if (new FileInfo(overlayPath).Length != overlay.Size) + { + return (null, "The prepared-content overlay size changed."); + } + + try + { + PakIdentity baseIdentity = ReadPakIdentity(baseRecord.PreparedAssetPath); + PakIdentity overlayIdentity = ReadPakIdentity(overlayPath); + if (baseIdentity.FormatVersion != PakFormatVersion + || overlayIdentity.FormatVersion != PakFormatVersion) + { + return (null, "The base or overlay pak format is not supported."); + } + + if (baseIdentity.RecipeVersion != baseRecord.BakeToolVersion + || overlayIdentity.RecipeVersion != overlay.RecipeVersion) + { + return (null, "The base or overlay content recipe does not match its record."); + } + + if (!baseIdentity.SameDatSet(overlayIdentity)) + { + return (null, "The prepared-content overlay was built from a different DAT set."); + } + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or InvalidDataException) + { + return (null, $"The prepared-content package header is invalid: {ex.Message}"); + } + + if (forceFullVerification) + { + string baseSha = await _computeSha256( + baseRecord.PreparedAssetPath, + cancellationToken) + .ConfigureAwait(false); + if (!FileIntegrity.Matches(baseSha, baseRecord.PreparedAssetSha256)) + { + return (null, "The base prepared package SHA-256 does not match its record."); + } + + string overlaySha = await _computeSha256(overlayPath, cancellationToken) + .ConfigureAwait(false); + if (!FileIntegrity.Matches(overlaySha, overlay.Sha256)) + { + return (null, "The prepared-content overlay SHA-256 does not match its record."); + } + } + + return (state, null); + } + + public async Task SaveAtomicallyAsync( + LauncherInstallRecord baseRecord, + LauncherContentState state, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(baseRecord); + ArgumentNullException.ThrowIfNull(state); + string? error = ValidateContract(baseRecord, state); + if (error is not null) + { + throw new InvalidDataException(error); + } + + string overlayPath = GetOverlayPath(state.Overlay!); + string? candidateError = ValidateCandidate( + baseRecord, + overlayPath, + state.Overlay!); + if (candidateError is not null) + { + throw new InvalidDataException(candidateError); + } + + Directory.CreateDirectory(_pakDirectory); + string temporaryPath = StatePath + $".{Guid.NewGuid():N}.tmp"; + try + { + await using (FileStream stream = new( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 4096, + FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await JsonSerializer.SerializeAsync( + stream, + state, + SerializerOptions, + cancellationToken) + .ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + stream.Flush(flushToDisk: true); + } + + cancellationToken.ThrowIfCancellationRequested(); + File.Move(temporaryPath, StatePath, overwrite: true); + } + finally + { + LauncherInstallRecordStore.TryDelete(temporaryPath); + } + } + + public void Delete() => LauncherInstallRecordStore.TryDelete(StatePath); + + public string? ValidateCandidate( + LauncherInstallRecord baseRecord, + string overlayPath, + LauncherContentOverlay overlay) + { + ArgumentNullException.ThrowIfNull(baseRecord); + ArgumentException.ThrowIfNullOrWhiteSpace(overlayPath); + ArgumentNullException.ThrowIfNull(overlay); + if (!File.Exists(baseRecord.PreparedAssetPath)) + { + return "The base prepared package is missing."; + } + + if (!File.Exists(overlayPath) + || new FileInfo(overlayPath).Length != overlay.Size) + { + return "The prepared-content overlay candidate size changed."; + } + + try + { + PakIdentity baseIdentity = ReadPakIdentity(baseRecord.PreparedAssetPath); + PakIdentity overlayIdentity = ReadPakIdentity(overlayPath); + if (baseIdentity.FormatVersion != PakFormatVersion + || overlayIdentity.FormatVersion != PakFormatVersion + || baseIdentity.RecipeVersion != baseRecord.BakeToolVersion + || overlayIdentity.RecipeVersion != overlay.RecipeVersion + || !baseIdentity.SameDatSet(overlayIdentity)) + { + return "The prepared-content overlay candidate header does not " + + "match the base pak and requested recipe."; + } + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or InvalidDataException) + { + return $"The prepared-content overlay candidate is invalid: {ex.Message}"; + } + + return null; + } + + private static string? ValidateContract( + LauncherInstallRecord baseRecord, + LauncherContentState? state) + { + if (state is null) + { + return "The prepared-content update record is empty."; + } + + if (state.SchemaVersion != LauncherContentState.CurrentSchemaVersion) + { + return $"Prepared-content record version {state.SchemaVersion} is not supported."; + } + + if (!IsSha256(state.BaseSha256) + || !FileIntegrity.Matches(state.BaseSha256, baseRecord.PreparedAssetSha256)) + { + return "The prepared-content update record does not match the installed base pak."; + } + + if (state.Overlay is null) + { + return "The prepared-content update record is missing its overlay."; + } + + if (state.EffectiveRecipeVersion != state.Overlay.RecipeVersion + || state.EffectiveRecipeVersion <= baseRecord.BakeToolVersion) + { + return "The prepared-content overlay recipe is not a newer effective recipe."; + } + + if (!IsSha256(state.Overlay.Sha256) || state.Overlay.Size <= 0) + { + return "The prepared-content overlay is missing valid integrity metadata."; + } + + return ValidateOverlayFileName(state.Overlay.Path); + } + + private static string? ValidateOverlayFileName(string path) + { + if (string.IsNullOrWhiteSpace(path) + || Path.IsPathFullyQualified(path) + || !string.Equals(path, Path.GetFileName(path), StringComparison.Ordinal) + || path is "." or ".." + || !path.EndsWith(".pak", StringComparison.OrdinalIgnoreCase)) + { + return "The prepared-content overlay path must be one pak filename beneath the pak directory."; + } + + return null; + } + + private static PakIdentity ReadPakIdentity(string path) + { + Span header = stackalloc byte[PakHeaderSize]; + using FileStream stream = new(path, FileMode.Open, FileAccess.Read, FileShare.Read); + stream.ReadExactly(header); + uint magic = BinaryPrimitives.ReadUInt32LittleEndian(header[0..4]); + if (magic != PakMagic) + { + throw new InvalidDataException("pak magic does not match ACPK"); + } + + return new PakIdentity( + BinaryPrimitives.ReadUInt32LittleEndian(header[4..8]), + BinaryPrimitives.ReadUInt32LittleEndian(header[8..12]), + BinaryPrimitives.ReadUInt32LittleEndian(header[12..16]), + BinaryPrimitives.ReadUInt32LittleEndian(header[16..20]), + BinaryPrimitives.ReadUInt32LittleEndian(header[20..24]), + BinaryPrimitives.ReadUInt32LittleEndian(header[36..40])); + } + + private static bool IsSha256(string value) => + value.Length == 64 && value.All(Uri.IsHexDigit); + + private readonly record struct PakIdentity( + uint FormatVersion, + uint PortalIteration, + uint CellIteration, + uint HighResIteration, + uint LanguageIteration, + uint RecipeVersion) + { + public bool SameDatSet(PakIdentity other) => + PortalIteration == other.PortalIteration + && CellIteration == other.CellIteration + && HighResIteration == other.HighResIteration + && LanguageIteration == other.LanguageIteration; + } +} diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs index 1b52cf84..f96badb7 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs @@ -10,15 +10,20 @@ public enum InstallRecordVerificationState { Missing, Verified, + ContentUpdateRequired, Invalid, } public sealed record InstallRecordVerification( InstallRecordVerificationState State, LauncherInstallRecord? Record, - string Status) + string Status, + ContentMigrationPlan? RequiredContentWork = null) { public bool IsVerified => State == InstallRecordVerificationState.Verified; + + public bool RequiresContentUpdate => + State == InstallRecordVerificationState.ContentUpdateRequired; } /// @@ -77,7 +82,8 @@ public sealed class LauncherInstallRecordStore /// passes false. public async Task LoadAndVerifyAsync( CancellationToken cancellationToken = default, - bool forceFullVerification = false) + bool forceFullVerification = false, + IProgress? progress = null) { await using InstallerTransactionLease lease = await InstallerTransactionLease.AcquireAsync( @@ -86,13 +92,15 @@ public sealed class LauncherInstallRecordStore .ConfigureAwait(false); return await LoadAndVerifyUnderLeaseAsync( cancellationToken, - forceFullVerification) + forceFullVerification, + progress) .ConfigureAwait(false); } internal async Task LoadAndVerifyUnderLeaseAsync( CancellationToken cancellationToken = default, - bool forceFullVerification = false) + bool forceFullVerification = false, + IProgress? progress = null) { if (!File.Exists(RecordPath)) { @@ -146,18 +154,59 @@ public sealed class LauncherInstallRecordStore string? contractError = ValidateRecordContract( record, - requireCanonicalSerializedPaths: true); + requireCanonicalSerializedPaths: true, + requireCurrentRecipe: false); if (contractError is not null) { return Invalid(contractError); } + if (record.BakeToolVersion > CurrentBakeToolVersion) + { + return Invalid( + $"Prepared content recipe {record.BakeToolVersion} is newer than " + + $"this launcher's recipe {CurrentBakeToolVersion}. Update the launcher."); + } + + if (record.BakeToolVersion < CurrentBakeToolVersion) + { + if (!File.Exists(record.PreparedAssetPath)) + { + return Invalid("The prepared package is missing."); + } + + if (new FileInfo(record.PreparedAssetPath).Length + != record.PreparedAssetSize) + { + return Invalid("The prepared package size changed."); + } + + ContentMigrationPlan plan; + try + { + plan = ContentMigrationCatalog.Resolve( + record.BakeToolVersion, + CurrentBakeToolVersion); + } + catch (InvalidOperationException ex) + { + return Invalid(ex.Message); + } + + return new InstallRecordVerification( + InstallRecordVerificationState.ContentUpdateRequired, + record, + $"World data update required: {plan.Reason}.", + plan); + } + string backupPath = GetBackupPath(record.PreparedAssetPath); FileVerification current = await VerifyFileAsync( record.PreparedAssetPath, record, cancellationToken, - allowCachedResult: !forceFullVerification) + allowCachedResult: !forceFullVerification, + progress) .ConfigureAwait(false); if (current.IsValid) { @@ -176,7 +225,8 @@ public sealed class LauncherInstallRecordStore backupPath, record, cancellationToken, - allowCachedResult: false) + allowCachedResult: false, + progress) .ConfigureAwait(false); if (backup.IsValid) { @@ -220,7 +270,8 @@ public sealed class LauncherInstallRecordStore LauncherInstallRecord normalized = NormalizeForSave(record); string? contractError = ValidateRecordContract( normalized, - requireCanonicalSerializedPaths: true); + requireCanonicalSerializedPaths: true, + requireCurrentRecipe: true); if (contractError is not null) { throw new InvalidDataException(contractError); @@ -263,9 +314,37 @@ public sealed class LauncherInstallRecordStore } } + /// Records the hash the installer just computed so the first + /// launch after a successful bake does not immediately hash the same + /// multi-gigabyte file again. + internal void RememberVerifiedPackage(LauncherInstallRecord record) + { + ArgumentNullException.ThrowIfNull(record); + try + { + var file = new FileInfo(record.PreparedAssetPath); + if (file.Exists && file.Length == record.PreparedAssetSize) + { + _verificationCache.Write( + record.PreparedAssetPath, + file.Length, + file.LastWriteTimeUtc, + record.PreparedAssetSha256); + } + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException) + { + // The cache is only an optimization. Startup will hash visibly. + } + } + private string? ValidateRecordContract( LauncherInstallRecord record, - bool requireCanonicalSerializedPaths) + bool requireCanonicalSerializedPaths, + bool requireCurrentRecipe) { if (record.Version != LauncherInstallRecord.CurrentRecordVersion) { @@ -277,7 +356,8 @@ public sealed class LauncherInstallRecordStore return "The install record is missing SHA-256, size, or bake-tool metadata."; } - if (record.BakeToolVersion != CurrentBakeToolVersion) + if (requireCurrentRecipe + && record.BakeToolVersion != CurrentBakeToolVersion) { return $"Bake tool version {record.BakeToolVersion} is not supported; " + $"version {CurrentBakeToolVersion} is required."; @@ -422,7 +502,8 @@ public sealed class LauncherInstallRecordStore string path, LauncherInstallRecord record, CancellationToken cancellationToken, - bool allowCachedResult) + bool allowCachedResult, + IProgress? progress) { if (!File.Exists(path)) { @@ -453,6 +534,12 @@ public sealed class LauncherInstallRecordStore return new FileVerification(true, "Client content verified."); } + progress?.Report( + allowCachedResult + ? "The verification cache is missing or changed. Reading the " + + "whole world-data pak once; this can take around 30 seconds." + : "Reading the whole world-data pak for explicit verification; " + + "this can take around 30 seconds."); string sha256 = await _computeSha256(path, cancellationToken) .ConfigureAwait(false); if (!FileIntegrity.Matches(sha256, record.PreparedAssetSha256)) diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs index 4deae741..ea5b9949 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs @@ -61,25 +61,55 @@ public interface ILauncherInstaller CancellationToken cancellationToken = default, bool forceFullVerification = false); + Task LoadExistingWithProgressAsync( + CancellationToken cancellationToken = default, + bool forceFullVerification = false, + IProgress? progress = null) => + LoadExistingAsync(cancellationToken, forceFullVerification); + Task InstallAsync( string datDirectory, int threads, IProgress? progress = null, CancellationToken cancellationToken = default); + + Task ApplyContentUpdateAsync( + string datDirectory, + int threads, + ContentMigrationPlan migration, + IProgress? progress = null, + CancellationToken cancellationToken = default) => + migration.Kind == ContentWorkKind.FullRebuild + ? InstallAsync( + datDirectory, + threads, + progress, + cancellationToken) + : Task.FromException( + new NotSupportedException( + "This installer does not support filtered content overlays.")); + + /// Clears the crash-safe gate left by a completed content + /// migration after the active client has been confirmed compatible. + void ConfirmClientCompatibility() + { + } } /// /// BCL-only first-run transaction. It invokes the GL-free bake executable as /// a child, consumes only its versioned JSONL records, verifies the published -/// pak, and atomically records the install. A prior verified package is moved -/// to an adjacent recovery slot and restored on every failure/cancellation -/// path, so a fake or crashed child cannot replace it with partial output. +/// pak, and atomically records the install. Long full rebuilds and filtered +/// overlays are written beside active content; the old package is touched only +/// during the final verified publication, so cancellation and child failure +/// leave the playable bytes in place. /// public sealed class LauncherInstaller : ILauncherInstaller { private readonly string _bakeExecutablePath; private readonly DatDirectoryLocator _datDirectories; private readonly LauncherInstallRecordStore _recordStore; + private readonly LauncherContentStateStore _contentStateStore; private readonly IBakeProcessRunner _processRunner; private readonly Func> _computeSha256; private readonly SemaphoreSlim _installGate = new(1, 1); @@ -96,6 +126,7 @@ public sealed class LauncherInstaller : ILauncherInstaller string bakeExecutablePath, DatDirectoryLocator? datDirectories = null, LauncherInstallRecordStore? recordStore = null, + LauncherContentStateStore? contentStateStore = null, IBakeProcessRunner? processRunner = null, Func>? computeSha256 = null) { @@ -111,6 +142,8 @@ public sealed class LauncherInstaller : ILauncherInstaller paths, _datDirectories, _computeSha256); + _contentStateStore = contentStateStore + ?? new LauncherContentStateStore(paths, _computeSha256); _processRunner = processRunner ?? new SystemBakeProcessRunner(); } @@ -120,9 +153,21 @@ public sealed class LauncherInstaller : ILauncherInstaller public DatDirectoryValidation ValidateDatDirectory(string? directory) => _datDirectories.Validate(directory); + public void ConfirmClientCompatibility() => + _contentStateStore.ClearClientCompatibilityPending(); + public async Task LoadExistingAsync( CancellationToken cancellationToken = default, - bool forceFullVerification = false) + bool forceFullVerification = false) => + await LoadExistingWithProgressAsync( + cancellationToken, + forceFullVerification) + .ConfigureAwait(false); + + public async Task LoadExistingWithProgressAsync( + CancellationToken cancellationToken = default, + bool forceFullVerification = false, + IProgress? progress = null) { await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false); try @@ -136,8 +181,28 @@ public sealed class LauncherInstaller : ILauncherInstaller InstallRecordVerification verification = await RecoverExistingUnderPublicationGuardAsync( cancellationToken, - forceFullVerification) + forceFullVerification, + progress) .ConfigureAwait(false); + verification = await ResolveContentStateAsync( + verification, + forceFullVerification, + cancellationToken) + .ConfigureAwait(false); + if (verification.IsVerified + && verification.Record is not null + && _contentStateStore.IsClientCompatibilityPending) + { + verification = verification with + { + Record = verification.Record with + { + RequiresClientCompatibilityConfirmation = true, + }, + Status = "World data is verified; matching client confirmation is pending.", + }; + } + _verifiedRecord = verification.Record; return verification; } @@ -210,6 +275,7 @@ public sealed class LauncherInstaller : ILauncherInstaller } string outputPath = _recordStore.PreparedAssetPath; + string bakeOutputPath = GetFullRebuildCandidatePath(outputPath); string backupPath = LauncherInstallRecordStore.GetBackupPath(outputPath); InstallRecordVerification existing = await RecoverExistingUnderPublicationGuardAsync( @@ -221,6 +287,15 @@ public sealed class LauncherInstaller : ILauncherInstaller forceFullVerification: true) .ConfigureAwait(false); _verifiedRecord = existing.Record; + LauncherContentState? priorContentState = null; + if (existing.Record is not null) + { + (priorContentState, _) = await _contentStateStore.LoadAsync( + existing.Record, + forceFullVerification: false, + cancellationToken) + .ConfigureAwait(false); + } Directory.CreateDirectory( Path.GetDirectoryName(outputPath) @@ -230,12 +305,11 @@ public sealed class LauncherInstaller : ILauncherInstaller Report( progress, LauncherInstallPhase.PreparingOutput, - "Preparing the atomic package transaction..."); - bool previousPreserved = PreservePreviousPackage(outputPath, backupPath); - if (!previousPreserved) - { - LauncherInstallRecordStore.TryDelete(backupPath); - } + "Preparing a replacement beside the active package..."); + LauncherInstallRecordStore.TryDelete(bakeOutputPath); + LauncherInstallRecordStore.TryDelete(backupPath); + bool previousPreserved = false; + bool canonicalReplaced = false; var parser = new BakeProgressJsonlParser(); var protocol = new BakeProgressProtocol(); @@ -289,12 +363,12 @@ public sealed class LauncherInstaller : ILauncherInstaller await using ( BakePublicationGuardContract.PublicationLease publication = await BakePublicationGuardContract.AcquireAsync( - outputPath, + bakeOutputPath, cancellationToken) .ConfigureAwait(false)) { BakePublicationGuardContract.Authorize( - outputPath, + bakeOutputPath, publicationNonce, publication); } @@ -302,7 +376,7 @@ public sealed class LauncherInstaller : ILauncherInstaller var request = new BakeProcessRequest( _bakeExecutablePath, validation.Directory, - outputPath, + bakeOutputPath, threads, publicationNonce); BakeProcessResult processResult = await _processRunner.RunAsync( @@ -368,13 +442,13 @@ public sealed class LauncherInstaller : ILauncherInstaller $"The bake completed with {completed.Failures:N0} failed assets."); } - if (!File.Exists(outputPath)) + if (!File.Exists(bakeOutputPath)) { throw new LauncherInstallException( "The bake tool reported success but did not publish acdream.pak."); } - long size = new FileInfo(outputPath).Length; + long size = new FileInfo(bakeOutputPath).Length; if (size <= 0 || size != completed.OutputBytes) { throw new LauncherInstallException( @@ -385,7 +459,7 @@ public sealed class LauncherInstaller : ILauncherInstaller progress, LauncherInstallPhase.VerifyingPackage, "Computing the prepared package SHA-256..."); - string sha256 = await _computeSha256(outputPath, cancellationToken) + string sha256 = await _computeSha256(bakeOutputPath, cancellationToken) .ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); @@ -398,15 +472,31 @@ public sealed class LauncherInstaller : ILauncherInstaller Report( progress, LauncherInstallPhase.SavingRecord, - "Saving the verified install record..."); + "Activating the verified package..."); + previousPreserved = PreservePreviousPackage(outputPath, backupPath); + File.Move(bakeOutputPath, outputPath, overwrite: true); + canonicalReplaced = true; await _recordStore.SaveAtomicallyUnderLeaseAsync( record, cancellationToken) .ConfigureAwait(false); + _recordStore.RememberVerifiedPackage(record); + + // A complete current-recipe base supersedes every overlay. Publish + // the base record first, then remove the optional sidecar so a + // crash can at worst leave a sidecar whose base digest no longer + // binds and which startup therefore rejects. + _contentStateStore.Delete(); + if (priorContentState?.Overlay is not null) + { + LauncherInstallRecordStore.TryDelete( + _contentStateStore.GetOverlayPath( + priorContentState.Overlay)); + } _verifiedRecord = record; await FinalizeSuccessfulPublicationAsync( - outputPath, + bakeOutputPath, backupPath, publicationNonce) .ConfigureAwait(false); @@ -421,9 +511,11 @@ public sealed class LauncherInstaller : ILauncherInstaller catch (OperationCanceledException) { await FinalizeFailedPublicationAsync( + bakeOutputPath, outputPath, backupPath, previousPreserved, + canonicalReplaced, publicationNonce) .ConfigureAwait(false); Report( @@ -435,9 +527,11 @@ public sealed class LauncherInstaller : ILauncherInstaller catch (Exception ex) { await FinalizeFailedPublicationAsync( + bakeOutputPath, outputPath, backupPath, previousPreserved, + canonicalReplaced, publicationNonce) .ConfigureAwait(false); Report( @@ -456,9 +550,42 @@ public sealed class LauncherInstaller : ILauncherInstaller private async Task RecoverExistingUnderPublicationGuardAsync( CancellationToken cancellationToken, - bool forceFullVerification = false) + bool forceFullVerification = false, + IProgress? progress = null) { string outputPath = _recordStore.PreparedAssetPath; + string candidatePath = GetFullRebuildCandidatePath(outputPath); + await using ( + BakePublicationGuardContract.PublicationLease candidatePublication = + await BakePublicationGuardContract.AcquireAsync( + candidatePath, + cancellationToken, + PublicationLeaseContentionObservedForTest) + .ConfigureAwait(false)) + { + BakePublicationGuardContract.Invalidate( + candidatePath, + candidatePublication); + LauncherInstallRecordStore.TryDelete(candidatePath); + BakeOutputStagingContract.DeleteOwnedStagingFiles(candidatePath); + } + + string overlayCandidatePath = _contentStateStore.OverlayCandidatePath; + await using ( + BakePublicationGuardContract.PublicationLease overlayPublication = + await BakePublicationGuardContract.AcquireAsync( + overlayCandidatePath, + cancellationToken) + .ConfigureAwait(false)) + { + BakePublicationGuardContract.Invalidate( + overlayCandidatePath, + overlayPublication); + LauncherInstallRecordStore.TryDelete(overlayCandidatePath); + BakeOutputStagingContract.DeleteOwnedStagingFiles( + overlayCandidatePath); + } + await using BakePublicationGuardContract.PublicationLease publication = await BakePublicationGuardContract.AcquireAsync( outputPath, @@ -472,10 +599,473 @@ public sealed class LauncherInstaller : ILauncherInstaller BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); return await _recordStore.LoadAndVerifyUnderLeaseAsync( cancellationToken, - forceFullVerification) + forceFullVerification, + progress) .ConfigureAwait(false); } + public async Task ApplyContentUpdateAsync( + string datDirectory, + int threads, + ContentMigrationPlan migration, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(migration); + if (migration.Kind is not ContentWorkKind.FullRebuild + and not ContentWorkKind.Overlay) + { + throw new LauncherInstallException( + $"Content work kind {migration.Kind} cannot build an update."); + } + + if (threads <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(threads), + "Bake thread count must be positive."); + } + + await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false); + bool compatibilityMarkerPublished = false; + try + { + await using InstallerTransactionLease lease = + await InstallerTransactionLease.AcquireAsync( + _recordStore.DataDirectory, + cancellationToken, + TransactionLeaseContentionObservedForTest) + .ConfigureAwait(false); + // Publish the tiny gate before changing any content bytes. A + // crash can therefore leave an unnecessary confirmation prompt, + // but can never forget a required one after the pak changes. + _contentStateStore.MarkClientCompatibilityPending(); + compatibilityMarkerPublished = true; + LauncherInstallResult result = migration.Kind == ContentWorkKind.FullRebuild + ? await InstallCoreAsync( + datDirectory, + threads, + progress, + cancellationToken) + .ConfigureAwait(false) + : await InstallOverlayCoreAsync( + datDirectory, + threads, + migration, + progress, + cancellationToken) + .ConfigureAwait(false); + LauncherInstallRecord gatedRecord = result.Record with + { + RequiresClientCompatibilityConfirmation = true, + }; + _verifiedRecord = gatedRecord; + return new LauncherInstallResult(gatedRecord); + } + catch + { + if (compatibilityMarkerPublished) + { + _contentStateStore.ClearClientCompatibilityPending(); + } + + throw; + } + finally + { + _installGate.Release(); + } + } + + private async Task InstallOverlayCoreAsync( + string datDirectory, + int threads, + ContentMigrationPlan migration, + IProgress? progress, + CancellationToken cancellationToken) + { + if (migration.TargetRecipeVersion + != LauncherInstallRecordStore.CurrentBakeToolVersion) + { + throw new LauncherInstallException( + $"Overlay target recipe {migration.TargetRecipeVersion} does not " + + $"match launcher recipe " + + $"{LauncherInstallRecordStore.CurrentBakeToolVersion}."); + } + + if (migration.EffectiveDatIds.Count == 0 + && migration.EffectiveLandblocks.Count == 0) + { + throw new LauncherInstallException( + "An overlay migration must name at least one DAT id or landblock."); + } + + DatDirectoryValidation validation = _datDirectories.Validate(datDirectory); + if (!validation.IsValid) + { + throw new LauncherInstallException( + validation.Message + FormatMissing(validation.MissingFileNames)); + } + + if (!File.Exists(_bakeExecutablePath)) + { + throw new LauncherInstallException( + $"The co-deployed bake tool is missing at '{_bakeExecutablePath}'."); + } + + InstallRecordVerification baseVerification = + await RecoverExistingUnderPublicationGuardAsync(cancellationToken) + .ConfigureAwait(false); + LauncherInstallRecord baseRecord = baseVerification.Record + ?? throw new LauncherInstallException( + "A verified base pak is required before building an overlay."); + if (baseRecord.BakeToolVersion != migration.FromRecipeVersion) + { + throw new LauncherInstallException( + $"The overlay plan starts at recipe {migration.FromRecipeVersion}, " + + $"but the installed base is recipe {baseRecord.BakeToolVersion}."); + } + + (LauncherContentState? priorState, string? priorStateError) = + await _contentStateStore.LoadAsync( + baseRecord, + forceFullVerification: false, + cancellationToken) + .ConfigureAwait(false); + if (priorStateError is not null) + { + throw new LauncherInstallException(priorStateError); + } + + string candidatePath = _contentStateStore.OverlayCandidatePath; + LauncherInstallRecordStore.TryDelete(candidatePath); + string? publicationNonce = null; + string? publishedOverlayPath = null; + bool statePublished = false; + var parser = new BakeProgressJsonlParser(); + var protocol = new BakeProgressProtocol(); + + void Observe(BakeProgressEvent progressEvent) + { + bool accepted = protocol.Observe(progressEvent); + switch (progressEvent) + { + case BakeWorkProgressEvent value when accepted: + LauncherInstallPhase phase = value.Phase == "collision" + ? LauncherInstallPhase.BakingCollision + : LauncherInstallPhase.BakingMeshes; + Report( + progress, + phase, + $"Building world-data overlay: {value.Completed:N0}/" + + $"{value.Total:N0}; failures: {value.Failures:N0}", + value.Completed, + value.Total, + value.Failures, + value.EtaSeconds); + break; + case BakeErrorEvent value when accepted: + Report(progress, LauncherInstallPhase.Failed, value.Message); + break; + case MalformedBakeProgressEvent value: + Report(progress, LauncherInstallPhase.Failed, value.Reason); + break; + } + } + + try + { + Report( + progress, + LauncherInstallPhase.PreparingOutput, + "Preparing a small filtered overlay beside active content..."); + publicationNonce = BakePublicationGuardPaths.CreateNonce(); + await using ( + BakePublicationGuardContract.PublicationLease publication = + await BakePublicationGuardContract.AcquireAsync( + candidatePath, + cancellationToken) + .ConfigureAwait(false)) + { + BakePublicationGuardContract.Authorize( + candidatePath, + publicationNonce, + publication); + } + + var request = new BakeProcessRequest( + _bakeExecutablePath, + validation.Directory, + candidatePath, + threads, + publicationNonce, + migration.EffectiveDatIds, + migration.EffectiveLandblocks); + BakeProcessResult processResult = await _processRunner.RunAsync( + request, + chunk => + { + foreach (BakeProgressEvent value in parser.Append(chunk)) + { + Observe(value); + } + }, + cancellationToken) + .ConfigureAwait(false); + foreach (BakeProgressEvent value in parser.Complete()) + { + Observe(value); + } + + protocol.CompleteInput(); + cancellationToken.ThrowIfCancellationRequested(); + if (protocol.Violation is not null) + { + throw new LauncherInstallException(protocol.Violation); + } + + if (processResult.ExitCode != 0) + { + throw new LauncherInstallException( + BuildChildFailure( + processResult.ExitCode, + protocol.Error?.Message, + processResult.StandardError)); + } + + if (protocol.Error is not null) + { + throw new LauncherInstallException(protocol.Error.Message); + } + + BakeStartedEvent started = protocol.Started + ?? throw new LauncherInstallException( + "The bake protocol did not report a started event."); + BakeCompletedEvent completed = protocol.Completed + ?? throw new LauncherInstallException( + "The bake protocol did not report a completed event."); + if (started.BakeToolVersion != migration.TargetRecipeVersion + || completed.BakeToolVersion != migration.TargetRecipeVersion + || completed.Failures != 0) + { + throw new LauncherInstallException( + "The filtered bake did not complete with the requested recipe."); + } + + if (!File.Exists(candidatePath)) + { + throw new LauncherInstallException( + "The filtered bake did not publish an overlay candidate."); + } + + long size = new FileInfo(candidatePath).Length; + if (size <= 0 || size != completed.OutputBytes) + { + throw new LauncherInstallException( + "The overlay candidate size does not match bake completion."); + } + + Report( + progress, + LauncherInstallPhase.VerifyingPackage, + "Verifying the small world-data overlay..."); + string sha256 = await _computeSha256(candidatePath, cancellationToken) + .ConfigureAwait(false); + string fileName = $"acdream-update-{migration.TargetRecipeVersion}-" + + $"{sha256[..12].ToLowerInvariant()}.pak"; + var overlay = new LauncherContentOverlay( + fileName, + sha256, + size, + migration.TargetRecipeVersion); + string? candidateError = _contentStateStore.ValidateCandidate( + baseRecord, + candidatePath, + overlay); + if (candidateError is not null) + { + throw new LauncherInstallException(candidateError); + } + + publishedOverlayPath = _contentStateStore.GetOverlayPath(overlay); + File.Move(candidatePath, publishedOverlayPath, overwrite: true); + await FinalizeSuccessfulPublicationAsync( + candidatePath, + backupPath: candidatePath + ".unused", + publicationNonce) + .ConfigureAwait(false); + var state = new LauncherContentState( + LauncherContentState.CurrentSchemaVersion, + baseRecord.PreparedAssetSha256, + migration.TargetRecipeVersion, + overlay); + Report( + progress, + LauncherInstallPhase.SavingRecord, + "Activating the verified world-data overlay..."); + await _contentStateStore.SaveAtomicallyAsync( + baseRecord, + state, + cancellationToken) + .ConfigureAwait(false); + statePublished = true; + + if (priorState?.Overlay is not null) + { + string priorPath = _contentStateStore.GetOverlayPath( + priorState.Overlay); + if (!PathsEqual(priorPath, publishedOverlayPath)) + { + LauncherInstallRecordStore.TryDelete(priorPath); + } + } + + var resolvedRecord = baseRecord with + { + PreparedAssetOverlayPath = publishedOverlayPath, + EffectiveBakeToolVersion = migration.TargetRecipeVersion, + }; + _verifiedRecord = resolvedRecord; + Report( + progress, + LauncherInstallPhase.Completed, + "World data overlay installed and verified.", + 1, + 1); + return new LauncherInstallResult(resolvedRecord); + } + catch (OperationCanceledException) + { + await FinalizeOverlayFailureAsync( + candidatePath, + publishedOverlayPath, + statePublished, + publicationNonce) + .ConfigureAwait(false); + Report( + progress, + LauncherInstallPhase.Cancelled, + "World data update cancelled; active content was preserved."); + throw; + } + catch (Exception ex) + { + await FinalizeOverlayFailureAsync( + candidatePath, + publishedOverlayPath, + statePublished, + publicationNonce) + .ConfigureAwait(false); + Report( + progress, + LauncherInstallPhase.Failed, + $"World data update failed: {ex.Message}"); + if (ex is LauncherInstallException) + { + throw; + } + + throw new LauncherInstallException("World data update failed.", ex); + } + } + + private async Task ResolveContentStateAsync( + InstallRecordVerification baseVerification, + bool forceFullVerification, + CancellationToken cancellationToken) + { + LauncherInstallRecord? record = baseVerification.Record; + if (record is null) + { + return baseVerification; + } + + if (baseVerification.IsVerified + && record.BakeToolVersion + == LauncherInstallRecordStore.CurrentBakeToolVersion) + { + // A complete current-recipe base is sufficient by itself. A + // sidecar from a newer launcher may remain across client rollback; + // this client safely ignores it instead of rejecting the base. + return baseVerification; + } + + (LauncherContentState? state, string? error) = + await _contentStateStore.LoadAsync( + record, + forceFullVerification, + cancellationToken) + .ConfigureAwait(false); + if (error is not null) + { + if (!forceFullVerification + && baseVerification.RequiresContentUpdate) + { + return baseVerification with + { + Status = baseVerification.Status + + " The previous overlay was ignored because it is invalid.", + }; + } + + return new InstallRecordVerification( + InstallRecordVerificationState.Invalid, + null, + error); + } + + if (state?.Overlay is not null) + { + if (state.EffectiveRecipeVersion + > LauncherInstallRecordStore.CurrentBakeToolVersion) + { + return new InstallRecordVerification( + InstallRecordVerificationState.Invalid, + null, + $"Prepared content recipe {state.EffectiveRecipeVersion} " + + $"does not match required recipe " + + $"{LauncherInstallRecordStore.CurrentBakeToolVersion}."); + } + + if (state.EffectiveRecipeVersion + < LauncherInstallRecordStore.CurrentBakeToolVersion) + { + ContentMigrationPlan migration; + try + { + migration = ContentMigrationCatalog.Resolve( + record.BakeToolVersion, + LauncherInstallRecordStore.CurrentBakeToolVersion); + } + catch (InvalidOperationException ex) + { + return new InstallRecordVerification( + InstallRecordVerificationState.Invalid, + null, + ex.Message); + } + + return new InstallRecordVerification( + InstallRecordVerificationState.ContentUpdateRequired, + record, + $"World data update required: {migration.Reason}.", + migration); + } + + return new InstallRecordVerification( + InstallRecordVerificationState.Verified, + record with + { + PreparedAssetOverlayPath = + _contentStateStore.GetOverlayPath(state.Overlay), + EffectiveBakeToolVersion = state.EffectiveRecipeVersion, + }, + "Base and overlay client content verified."); + } + + return baseVerification; + } + private static async Task FinalizeSuccessfulPublicationAsync( string outputPath, string backupPath, @@ -495,22 +1085,58 @@ public sealed class LauncherInstaller : ILauncherInstaller } private static async Task FinalizeFailedPublicationAsync( + string publicationPath, string outputPath, string backupPath, bool previousPreserved, + bool canonicalReplaced, string? publicationNonce) { await using BakePublicationGuardContract.PublicationLease publication = await BakePublicationGuardContract.AcquireAsync( - outputPath, + publicationPath, CancellationToken.None) .ConfigureAwait(false); BakePublicationGuardContract.Invalidate( - outputPath, + publicationPath, publication, publicationNonce); - RestorePreviousPackage(outputPath, backupPath, previousPreserved); - BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); + if (canonicalReplaced) + { + LauncherInstallRecordStore.TryDelete(outputPath); + } + + if (previousPreserved && File.Exists(backupPath)) + { + File.Move(backupPath, outputPath, overwrite: true); + } + + LauncherInstallRecordStore.TryDelete(publicationPath); + LauncherInstallRecordStore.TryDelete(backupPath); + BakeOutputStagingContract.DeleteOwnedStagingFiles(publicationPath); + } + + private static async Task FinalizeOverlayFailureAsync( + string candidatePath, + string? publishedOverlayPath, + bool statePublished, + string? publicationNonce) + { + await using BakePublicationGuardContract.PublicationLease publication = + await BakePublicationGuardContract.AcquireAsync( + candidatePath, + CancellationToken.None) + .ConfigureAwait(false); + BakePublicationGuardContract.Invalidate( + candidatePath, + publication, + publicationNonce); + LauncherInstallRecordStore.TryDelete(candidatePath); + BakeOutputStagingContract.DeleteOwnedStagingFiles(candidatePath); + if (!statePublished && publishedOverlayPath is not null) + { + LauncherInstallRecordStore.TryDelete(publishedOverlayPath); + } } private bool PreservePreviousPackage(string outputPath, string backupPath) @@ -527,20 +1153,8 @@ public sealed class LauncherInstaller : ILauncherInstaller return true; } - private static void RestorePreviousPackage( - string outputPath, - string backupPath, - bool previousPreserved) - { - if (previousPreserved && File.Exists(backupPath)) - { - File.Move(backupPath, outputPath, overwrite: true); - return; - } - - LauncherInstallRecordStore.TryDelete(outputPath); - LauncherInstallRecordStore.TryDelete(backupPath); - } + internal static string GetFullRebuildCandidatePath(string outputPath) => + outputPath + ".candidate"; private static string BuildChildFailure( int exitCode, diff --git a/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs b/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs index c89c279a..92b71a12 100644 --- a/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs +++ b/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace AcDream.Launcher.Core.Launching; /// @@ -23,4 +25,29 @@ public sealed record LauncherInstallRecord( && PreparedAssetSha256.Length == 64 && PreparedAssetSize > 0 && BakeToolVersion > 0; + + /// + /// Resolved overlay metadata is runtime-only. It is explicitly excluded + /// from install.json so old strict-schema launchers continue accepting the + /// base record after a newer launcher publishes content.current.json. + /// + [JsonIgnore] + public string? PreparedAssetOverlayPath { get; init; } + + [JsonIgnore] + public uint EffectiveBakeToolVersion { get; init; } + + /// + /// Transient launcher gate restored from a tiny marker beside the pak. + /// It is not part of strict schema-1 install.json and is never copied into + /// a client session configuration. + /// + [JsonIgnore] + public bool RequiresClientCompatibilityConfirmation { get; init; } + + [JsonIgnore] + public uint ResolvedBakeToolVersion => + EffectiveBakeToolVersion == 0 + ? BakeToolVersion + : EffectiveBakeToolVersion; } diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs index d691ff1d..3eef5614 100644 --- a/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs +++ b/src/AcDream.Launcher.Core/Launching/SessionConfigComposer.cs @@ -114,6 +114,7 @@ public static class SessionConfigComposer ArgumentNullException.ThrowIfNull(install); ArgumentNullException.ThrowIfNull(paths); ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + EnsureClientCompatibilityConfirmed(install); (string configFilePath, string statusFilePath, string stderrLogPath) = BuildSessionPaths(paths, sessionId); @@ -154,6 +155,15 @@ public static class SessionConfigComposer { DatDirectory = install.DatDirectory, PreparedAssetPath = install.PreparedAssetPath, + PreparedAssetOverlayPath = install.PreparedAssetOverlayPath, + PreparedAssetBaseRecipeVersion = + install.PreparedAssetOverlayPath is null + ? null + : install.BakeToolVersion, + PreparedAssetEffectiveRecipeVersion = + install.PreparedAssetOverlayPath is null + ? null + : install.ResolvedBakeToolVersion, }, }, Sessions = [descriptor], @@ -188,6 +198,7 @@ public static class SessionConfigComposer ArgumentNullException.ThrowIfNull(install); ArgumentNullException.ThrowIfNull(paths); ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + EnsureClientCompatibilityConfirmed(install); (string configFilePath, string statusFilePath, string stderrLogPath) = BuildSessionPaths(paths, sessionId); @@ -219,6 +230,15 @@ public static class SessionConfigComposer { DatDirectory = install.DatDirectory, PreparedAssetPath = install.PreparedAssetPath, + PreparedAssetOverlayPath = install.PreparedAssetOverlayPath, + PreparedAssetBaseRecipeVersion = + install.PreparedAssetOverlayPath is null + ? null + : install.BakeToolVersion, + PreparedAssetEffectiveRecipeVersion = + install.PreparedAssetOverlayPath is null + ? null + : install.ResolvedBakeToolVersion, }, }, Sessions = [descriptor], @@ -304,6 +324,17 @@ public static class SessionConfigComposer return [.. configured]; } + private static void EnsureClientCompatibilityConfirmed( + LauncherInstallRecord install) + { + if (install.RequiresClientCompatibilityConfirmation) + { + throw new InvalidOperationException( + "Prepared content cannot be launched until the matching client " + + "has been confirmed or installed."); + } + } + private static ComposedSessionConfig Write(ComposedSessionConfig composed) { string? directory = Path.GetDirectoryName(composed.ConfigFilePath); diff --git a/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs index 1fc31f4d..83d00df8 100644 --- a/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs +++ b/src/AcDream.Launcher.Core/Launching/SessionConfigDocument.cs @@ -70,6 +70,15 @@ public sealed class SessionContentDescriptor public string DatDirectory { get; init; } = string.Empty; public string PreparedAssetPath { get; init; } = string.Empty; + + /// Present only for a layered prepared-content session. + public string? PreparedAssetOverlayPath { get; init; } + + /// Present with an overlay so the client can validate both pak + /// headers without assuming the base uses its current recipe. + public uint? PreparedAssetBaseRecipeVersion { get; init; } + + public uint? PreparedAssetEffectiveRecipeVersion { get; init; } } public sealed class SessionDescriptor diff --git a/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs index e52c443f..377d9f03 100644 --- a/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs +++ b/src/AcDream.Launcher.Core/Orchestration/ILauncherOrchestrator.cs @@ -27,6 +27,17 @@ public interface ILauncherOrchestrator : IDisposable void SetInstallRecord(LauncherInstallRecord? installRecord); + /// + /// Publishes the exact result of asynchronous launcher content discovery. + /// The default keeps test/injected implementations source-compatible; + /// production additionally retains + /// so a failed check is not flattened into a misleading first-run message. + /// + void SetInstallationState( + LauncherInstallRecord? installRecord, + string installationStatus) => + SetInstallRecord(installRecord); + void AddServer(string name, string host, int port); void EditServer(string name, string newName, string newHost, int newPort); diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs index d52acae7..8939e2a4 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs @@ -242,13 +242,23 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator public void SetInstallRecord(LauncherInstallRecord? installRecord) { + SetInstallationState( + installRecord, + installRecord is null + ? FirstRunRequired + : "Client content SHA-256, size, and bake-tool version verified."); + } + + public void SetInstallationState( + LauncherInstallRecord? installRecord, + string installationStatus) + { + ArgumentException.ThrowIfNullOrWhiteSpace(installationStatus); lock (_gate) { ThrowIfDisposed(); _installRecord = installRecord; - _installationStatus = installRecord is null - ? FirstRunRequired - : "Client content SHA-256, size, and bake-tool version verified."; + _installationStatus = installationStatus; } RaiseStateChanged(); diff --git a/src/AcDream.Launcher/App.axaml.cs b/src/AcDream.Launcher/App.axaml.cs index 73ed6bb3..d0f49756 100644 --- a/src/AcDream.Launcher/App.axaml.cs +++ b/src/AcDream.Launcher/App.axaml.cs @@ -49,23 +49,6 @@ public sealed partial class App : Application Path.Combine( AppContext.BaseDirectory, "acdream-bake" + executableSuffix)); - InstallRecordVerification verification; - try - { - // Hashing the package before constructing the orchestrator is - // intentional: no launch action is enabled until the persisted - // size/SHA/tool-version record has been verified. - verification = installer.LoadExistingAsync() - .GetAwaiter() - .GetResult(); - } - catch (Exception ex) - { - verification = new InstallRecordVerification( - InstallRecordVerificationState.Invalid, - null, - $"Client content verification failed: {ex.Message}"); - } LauncherUpdateComposition updates = LauncherUpdateComposition.Create( paths, @@ -81,8 +64,8 @@ public sealed partial class App : Application profiles, paths, updates.Executables, - verification.Record, - installationStatus: verification.Status, + installRecord: null, + installationStatus: "Checking installed game content…", updateSessionBarrier: updates.Versions.Barrier); // LU2: a launcher update installs and restarts by itself. The // helper waits on THIS process id and cannot replace files the @@ -108,18 +91,32 @@ public sealed partial class App : Application updates.Updater, applyLauncherUpdate, () => desktop.Shutdown()); - _viewModel.Initialize(); - - desktop.MainWindow = new MainWindow + var mainWindow = new MainWindow { DataContext = _viewModel, }; + desktop.MainWindow = mainWindow; + _viewModel.Initialize(); + mainWindow.Opened += OnMainWindowOpened; desktop.Exit += OnDesktopExit; } base.OnFrameworkInitializationCompleted(); } + private void OnMainWindowOpened(object? sender, EventArgs e) + { + if (sender is MainWindow window) + { + window.Opened -= OnMainWindowOpened; + } + + if (_viewModel is not null) + { + _ = _viewModel.StartBackgroundInitializationAsync(); + } + } + private void OnDesktopExit(object? sender, ControlledApplicationLifetimeExitEventArgs e) { _viewModel?.Dispose(); diff --git a/src/AcDream.Launcher/LauncherUpdateComposition.cs b/src/AcDream.Launcher/LauncherUpdateComposition.cs index e828c8b6..b2b86391 100644 --- a/src/AcDream.Launcher/LauncherUpdateComposition.cs +++ b/src/AcDream.Launcher/LauncherUpdateComposition.cs @@ -71,9 +71,15 @@ internal sealed class LauncherUpdateComposition : IDisposable ReleaseManifestClient? manifestClient = null; try { - _ = initialize is null - ? versions.LoadAndRecoverAsync(rid).GetAwaiter().GetResult() - : initialize(versions, rid); + // Production initialization is deliberately deferred until after + // MainWindow.Opened. Client-version recovery verifies every file + // in the active version and is therefore not composition-root + // work. The injectable callback remains only for focused failure + // composition tests. + if (initialize is not null) + { + _ = initialize(versions, rid); + } artifactClient = new HttpClient( new HttpClientHandler { diff --git a/src/AcDream.Launcher/MainWindow.axaml b/src/AcDream.Launcher/MainWindow.axaml index 7d877b3d..5b05836e 100644 --- a/src/AcDream.Launcher/MainWindow.axaml +++ b/src/AcDream.Launcher/MainWindow.axaml @@ -56,14 +56,15 @@ + IsVisible="{Binding ShowInstallationBanner}"> - + public List LoadExistingCalls { get; } = []; + public Func>? + LoadExistingHandler { get; set; } + public Task LoadExistingAsync( CancellationToken cancellationToken = default, bool forceFullVerification = false) { LoadExistingCalls.Add(forceFullVerification); - return Task.FromResult(NextVerification); + return LoadExistingHandler is null + ? Task.FromResult(NextVerification) + : LoadExistingHandler(cancellationToken); } public Task InstallAsync( @@ -919,5 +1193,88 @@ public sealed class LauncherWindowViewModelTests "Verifying package.")); return Task.FromResult(new LauncherInstallResult(Record)); } + + public void ConfirmClientCompatibility() => ConfirmCompatibilityCalls++; + } + + private sealed class StartupOrderUpdater : ILauncherUpdater + { + private static readonly LauncherVersion Version = LauncherVersion.Parse("1.0.0"); + private readonly ClientVersionResolution _resolution = new( + ClientVersionState.Missing, + "No versioned client is installed.", + null, + null, + null, + null); + + public int InitializeCalls { get; private set; } + + public int CheckCalls { get; private set; } + + public int InstallCalls { get; private set; } + + public bool ClientUpdateAvailable { get; init; } + + public TaskCompletionSource? CheckGate { get; init; } + + public ClientVersionResolution CurrentClient => _resolution; + + public Task InitializeAsync( + CancellationToken cancellationToken = default) + { + InitializeCalls++; + return Task.FromResult(_resolution); + } + + public async Task CheckAsync( + CancellationToken cancellationToken = default) + { + CheckCalls++; + if (CheckGate is not null) + { + _ = await CheckGate.Task.WaitAsync(cancellationToken); + } + + var artifact = new ReleaseArtifact( + new Uri("https://updates.example.test/acdream.zip"), + new string('a', 64), + 1); + var manifest = new ReleaseManifest( + Version, + Version, + new Dictionary { ["win-x64"] = artifact }, + new Dictionary { ["win-x64"] = artifact }); + return new LauncherUpdateCheckResult( + manifest, + "win-x64", + Version, + Version, + IsClientUpdateAvailable: ClientUpdateAvailable, + IsLauncherUpdateAvailable: false, + IsLauncherMinimumSatisfied: true, + "Everything is current."); + } + + public Task InstallClientAsync( + LauncherUpdateCheckResult check, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + InstallCalls++; + return Task.FromResult(_resolution); + } + + public Task StageLauncherAsync( + LauncherUpdateCheckResult check, + IProgress? progress = null, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task RollbackClientAsync( + IProgress? progress = null, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); } } From 0c699240e04c72b020fbdb3c9bc35c215ac5c22c Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 25 Aug 2026 19:38:10 +0200 Subject: [PATCH 80/89] fix(ci): make release gates portable and deterministic --- .../UI/Layout/CharacterStatController.cs | 12 ++++++------ .../Installation/LauncherContentStateStore.cs | 2 ++ .../UI/Layout/CharacterStatControllerTests.cs | 19 ++++++++++--------- .../PreparedAssetVerificationCacheTests.cs | 7 +++++++ 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index 550eb897..dd48e83d 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -2083,12 +2083,12 @@ public static class CharacterStatController long skillCost = skill.AdvancementClass >= CharacterSkillAdvancementClass.Trained ? skill.RaiseCost : skill.TrainedCost; - return skillCost > 0 ? skillCost.ToString("N0") : "Infinity!"; + return skillCost > 0 ? FormatXp(skillCost) : "Infinity!"; } if (attrSel[0] < 0) return sheet.SkillCredits.ToString(); long cost = GetRaiseCost(sheet, attrSel[0]); - return cost > 0 ? cost.ToString("N0") : "Infinity!"; + return cost > 0 ? FormatXp(cost) : "Infinity!"; }); // Line-2 elements: pass null → keep dat font. @@ -2114,7 +2114,7 @@ public static class CharacterStatController if (skill is not null && skill.AdvancementClass < CharacterSkillAdvancementClass.Trained) return sheet.SkillCredits.ToString(); } - return sheet.UnassignedXp.ToString("N0"); + return FormatXp(sheet.UnassignedXp); }); BindSelectedFooterState(stateB); @@ -2166,12 +2166,12 @@ public static class CharacterStatController long skillCost = skill.AdvancementClass >= CharacterSkillAdvancementClass.Trained ? skill.RaiseCost : skill.TrainedCost; - return skillCost > 0 ? skillCost.ToString("N0") : "Infinity!"; + return skillCost > 0 ? FormatXp(skillCost) : "Infinity!"; } if (attrSel[0] < 0) return sheet.SkillCredits.ToString(); long cost = GetRaiseCost(sheet, attrSel[0]); - return cost > 0 ? cost.ToString("N0") : "Infinity!"; + return cost > 0 ? FormatXp(cost) : "Infinity!"; }); LabelProvider(TextById(state, FooterLine2Label), null, Body, () => @@ -2194,7 +2194,7 @@ public static class CharacterStatController if (skill is not null && skill.AdvancementClass < CharacterSkillAdvancementClass.Trained) return sheet.SkillCredits.ToString(); } - return sheet.UnassignedXp.ToString("N0"); + return FormatXp(sheet.UnassignedXp); }); } } diff --git a/src/AcDream.Launcher.Core/Installation/LauncherContentStateStore.cs b/src/AcDream.Launcher.Core/Installation/LauncherContentStateStore.cs index 2a14452b..6e0e5e84 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherContentStateStore.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherContentStateStore.cs @@ -363,6 +363,8 @@ public sealed class LauncherContentStateStore { if (string.IsNullOrWhiteSpace(path) || Path.IsPathFullyQualified(path) + || path.Contains('/') + || path.Contains('\\') || !string.Equals(path, Path.GetFileName(path), StringComparison.Ordinal) || path is "." or ".." || !path.EndsWith(".pak", StringComparison.OrdinalIgnoreCase)) diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index 70d0913e..e80f46f3 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -1,5 +1,6 @@ using AcDream.App.UI; using AcDream.App.UI.Layout; +using System.Globalization; using System.Numerics; namespace AcDream.App.Tests.UI.Layout; @@ -184,8 +185,8 @@ public class CharacterStatControllerTests Assert.Equal("Non-Player Killer", visiblePk.LinesProvider()[0].Text); Assert.Equal("126", visibleLevel.LinesProvider()[0].Text); Assert.Equal("Total Experience (XP):", visibleTotalXpLabel.LinesProvider()[0].Text); - Assert.Equal((1_250_000_000L).ToString("N0"), visibleTotalXp.LinesProvider()[0].Text); - Assert.Equal((42_000_000L).ToString("N0"), visibleXpNext.LinesProvider()[0].Text); + Assert.Equal((1_250_000_000L).ToString("N0", CultureInfo.InvariantCulture), visibleTotalXp.LinesProvider()[0].Text); + Assert.Equal((42_000_000L).ToString("N0", CultureInfo.InvariantCulture), visibleXpNext.LinesProvider()[0].Text); Assert.Empty(hiddenName.LinesProvider()); Assert.Empty(hiddenXpNext.LinesProvider()); Assert.Empty(hiddenPk.LinesProvider()); @@ -682,7 +683,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter); - var expected = (87_757_321_741L).ToString("N0"); + var expected = (87_757_321_741L).ToString("N0", CultureInfo.InvariantCulture); Assert.Equal(expected, val.LinesProvider()[0].Text); } @@ -737,7 +738,7 @@ public class CharacterStatControllerTests Descendants(list).OfType().ToList()[4].OnClick!(); // Focus raise cost = 110 (SampleData fixture). - Assert.Equal((110L).ToString("N0"), val.LinesProvider()[0].Text); + Assert.Equal((110L).ToString("N0", CultureInfo.InvariantCulture), val.LinesProvider()[0].Text); } [Fact] @@ -768,7 +769,7 @@ public class CharacterStatControllerTests Descendants(list).OfType().ToList()[4].OnClick!(); // UnassignedXp = 87_757_321_741L - var expected = (87_757_321_741L).ToString("N0"); + var expected = (87_757_321_741L).ToString("N0", CultureInfo.InvariantCulture); Assert.Equal(expected, val.LinesProvider()[0].Text); } @@ -1443,9 +1444,9 @@ public class CharacterStatControllerTests // which was previously computed but never surfaced in the title text. Assert.Equal("War Magic: 285 (+5)", title.LinesProvider()[0].Text); Assert.Equal("Experience To Raise:", l1Label.LinesProvider()[0].Text); - Assert.Equal((11_100_000L).ToString("N0"), l1Value.LinesProvider()[0].Text); + Assert.Equal((11_100_000L).ToString("N0", CultureInfo.InvariantCulture), l1Value.LinesProvider()[0].Text); Assert.Equal("Unassigned Experience:", l2Label.LinesProvider()[0].Text); - Assert.Equal((87_757_321_741L).ToString("N0"), l2Value.LinesProvider()[0].Text); + Assert.Equal((87_757_321_741L).ToString("N0", CultureInfo.InvariantCulture), l2Value.LinesProvider()[0].Text); // CT5: RowHighlightSprite corrected to 0x06000F93 (see the sealed // verdict on RowClick_WithSpriteResolve_SelectedRowHasHighlightSprite // above); UseSelectionBars was retired outright (retail's actual @@ -2113,7 +2114,7 @@ public class CharacterStatControllerTests CharacterStatController.Bind(layout, SampleData.SampleCharacter); - Assert.Equal((1_250_000_000L).ToString("N0"), value.LinesProvider()[0].Text); + Assert.Equal((1_250_000_000L).ToString("N0", CultureInfo.InvariantCulture), value.LinesProvider()[0].Text); Assert.False(value.Centered); Assert.True(value.RightAligned); } @@ -2257,7 +2258,7 @@ public class CharacterStatControllerTests var lines = xpValue.LinesProvider(); Assert.Single(lines); // XpToNextLevel from SampleData = 42_000_000L formatted as "42,000,000" - Assert.Equal((42_000_000L).ToString("N0"), lines[0].Text); + Assert.Equal((42_000_000L).ToString("N0", CultureInfo.InvariantCulture), lines[0].Text); } /// diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/PreparedAssetVerificationCacheTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/PreparedAssetVerificationCacheTests.cs index a2d5d479..f0bc6925 100644 --- a/tests/AcDream.Launcher.Core.Tests/Installation/PreparedAssetVerificationCacheTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Installation/PreparedAssetVerificationCacheTests.cs @@ -238,11 +238,18 @@ public sealed class PreparedAssetVerificationCacheTests : IDisposable // Crash shape: the verified package was moved aside and what sits in // its place is wrong. The cache still describes the ORIGINAL bytes, so // if recovery trusted it the launcher would accept a bad package. + DateTime verifiedWriteTime = File.GetLastWriteTimeUtc(store.PreparedAssetPath); string backup = LauncherInstallRecordStore.GetBackupPath(store.PreparedAssetPath); File.Move(store.PreparedAssetPath, backup); await File.WriteAllTextAsync( store.PreparedAssetPath, new string('z', PackageContent.Length)); + File.SetLastWriteTimeUtc( + store.PreparedAssetPath, + verifiedWriteTime.AddMinutes(1)); + Assert.NotEqual( + verifiedWriteTime, + File.GetLastWriteTimeUtc(store.PreparedAssetPath)); InstallRecordVerification verification = await store.LoadAndVerifyAsync(); From f6fe0f2a4f40c43c0556e2ff598dbc6f0bf2a846 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 26 Aug 2026 20:45:11 +0200 Subject: [PATCH 81/89] fix(client): restore retail interaction parity Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation. --- docs/ISSUES.md | 283 ++++++- .../retail-divergence-register.md | 33 +- .../2026-08-10-options-panel-campaign.md | 12 +- ...-08-slice6-vendor-transactions-research.md | 55 +- ...-08-10-keyboard-config-and-gameplay-tab.md | 7 + .../2026-08-26-combined-client-parity-gate.md | 131 +++ ...26-08-26-issues-444-445-447-test-script.md | 43 + ...8-26-retail-inventory-interaction-audit.md | 745 ++++++++++++++++++ ...026-08-26-retail-keyboard-routing-audit.md | 115 +++ .../InteractionRetainedUiComposition.cs | 63 +- .../LivePresentationComposition.cs | 21 +- .../Composition/SessionPlayerComposition.cs | 8 +- .../SettingsDevToolsComposition.cs | 4 +- .../Diagnostics/FrameScreenshotController.cs | 30 + .../Input/CameraPointerInputController.cs | 82 ++ .../Input/DispatcherCameraInputSource.cs | 24 +- .../Input/GameplayInputActionRouter.cs | 132 +++- .../Input/GameplayInputCommandController.cs | 83 +- .../Input/GameplayInputFrameController.cs | 35 +- src/AcDream.App/Input/MouseLookController.cs | 4 +- .../Input/RetailEmoteMotionTable.cs | 129 +++ src/AcDream.App/Input/RetailKeymapFile.cs | 601 ++++++++++++++ .../SelectionInteractionController.cs | 188 ++++- .../Interaction/WorldSelectionQuery.cs | 219 ++++- src/AcDream.App/Net/DatChatPoseCatalog.cs | 81 ++ .../Net/LiveSessionCommandRouter.cs | 10 +- .../Net/LiveSessionRuntimeFactory.cs | 21 +- .../Rendering/CameraFrameController.cs | 22 + src/AcDream.App/Rendering/ChaseCamera.cs | 145 +++- src/AcDream.App/Rendering/GameWindow.cs | 42 +- .../Rendering/PaperdollFramePresenter.cs | 10 +- .../Rendering/PaperdollViewportRenderer.cs | 2 + .../PrivateEntityViewportRenderer.cs | 39 +- .../Rendering/RetailChaseCamera.cs | 178 ++++- .../Wb/WbDrawDispatcher.PackedOracle.cs | 10 +- .../Rendering/Wb/WbDrawDispatcher.Rhi.cs | 4 +- .../Rendering/Wb/WbDrawDispatcher.cs | 38 +- .../CurrentGameRuntimeCommandAdapter.cs | 22 + .../LocalPlayerTeleportController.cs | 39 +- src/AcDream.App/UI/AutoWieldController.cs | 73 +- .../UI/ItemInteractionController.cs | 217 ++++- .../UI/Layout/CharacterStatController.cs | 19 +- .../UI/Layout/ChatTranscriptRenderer.cs | 86 +- .../UI/Layout/ChatWindowController.cs | 39 + src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 41 +- .../UI/Layout/ExternalContainerController.cs | 138 +++- .../UI/Layout/InventoryController.cs | 285 ++++--- .../UI/Layout/JournalPanelController.cs | 3 + .../UI/Layout/KeyboardConfigController.cs | 346 +++++--- .../UI/Layout/MapHousePanelController.cs | 6 + src/AcDream.App/UI/Layout/OptionPageModel.cs | 18 +- .../UI/Layout/OptionsPanelController.cs | 22 + .../UI/Layout/PaperdollController.cs | 12 +- .../RetailConfirmationMenuDialogView.cs | 122 +++ src/AcDream.App/UI/Layout/RetailDialogData.cs | 32 + .../UI/Layout/RetailDialogFactory.cs | 30 +- src/AcDream.App/UI/Layout/RetailKeyNames.cs | 72 +- .../UI/Layout/SelectedObjectController.cs | 42 +- .../UI/Layout/SocialPanelController.cs | 4 + .../UI/Layout/SpellcastingUiController.cs | 24 +- .../UI/Layout/ToolbarInputController.cs | 17 + .../UI/Layout/VendorUiController.cs | 388 +++++++-- src/AcDream.App/UI/RetailUiRuntime.cs | 495 ++++++++++-- src/AcDream.App/UI/UiRoot.cs | 54 +- .../Messages/ClientCommandRequests.cs | 5 + src/AcDream.Core.Net/WorldSession.cs | 7 + .../Chat/ChatCommandTargetState.cs | 51 +- .../Chat/InventoryFailureMessages.cs | 9 +- src/AcDream.Core/Input/RetailActionMap.cs | 30 +- .../Items/ExternalContainerState.cs | 39 +- .../InventoryContainerPlacementPolicy.cs | 158 ++++ .../Items/InventoryTransactionState.cs | 2 + .../Items/ItemInteractionPolicy.cs | 72 +- src/AcDream.Core/Items/VendorStagingList.cs | 21 + src/AcDream.Core/Physics/MotionInterpreter.cs | 5 +- src/AcDream.Core/Physics/RawMotionState.cs | 26 + .../Chat/LiveChatCommandRoute.cs | 26 +- .../Chat/RetailPublicChatParser.cs | 76 ++ src/AcDream.Runtime/GameRuntimeActionViews.cs | 3 +- src/AcDream.Runtime/GameRuntimeCommands.cs | 6 + src/AcDream.Runtime/GameRuntimeViews.cs | 4 + .../Gameplay/LocalPlayerOutboundController.cs | 3 + .../Gameplay/PlayerMovementController.cs | 61 +- .../Gameplay/RuntimeActionState.cs | 3 +- .../Gameplay/RuntimeCombatAttackState.cs | 1 + .../Gameplay/RuntimeInventoryState.cs | 8 + .../RuntimeLocalPlayerMovementState.cs | 23 + .../DirectGameRuntimeCommandAdapter.cs | 19 + .../Input/InputAction.cs | 214 ++++- .../Input/InputDispatcher.cs | 268 +++++-- .../Input/InputScope.cs | 13 +- .../Input/KeyBindings.cs | 111 +-- .../Input/RetailActionIdentityTable.cs | 398 ++++++++-- .../Input/RetailScanCodeMap.cs | 177 ++++- .../Input/RetailUnmappedKeyBindings.cs | 16 +- .../Panels/Chat/ChatVM.cs | 6 + ...WorldLifecycleAutomationControllerTests.cs | 27 + .../CameraPointerInputControllerTests.cs | 42 +- .../Input/GameplayInputActionRouterTests.cs | 72 +- .../GameplayInputCommandControllerTests.cs | 64 +- .../GameplayInputFrameControllerTests.cs | 1 + .../Input/RetailEmoteMotionTableTests.cs | 45 ++ .../Input/RetailKeymapFileTests.cs | 113 +++ .../SelectionInteractionControllerTests.cs | 38 + .../Interaction/WorldSelectionQueryTests.cs | 194 ++++- .../Rendering/PaperdollFramePresenterTests.cs | 12 +- .../Rendering/RetailChaseCameraTests.cs | 54 ++ .../Runtime/CurrentGameRuntimeAdapterTests.cs | 9 + .../LocalPlayerTeleportControllerTests.cs | 31 + .../UI/AutoWieldGenerationTests.cs | 3 +- .../UI/DragDropSpineTests.cs | 32 + .../UI/ItemInteractionControllerTests.cs | 249 +++++- .../UI/Layout/CharacterStatControllerTests.cs | 24 +- .../UI/Layout/ChatTranscriptRunsTests.cs | 41 + .../UI/Layout/InventoryControllerTests.cs | 74 +- .../Layout/KeyboardConfigControllerTests.cs | 487 ++++++++++-- ...boardConfigInstalledDatConformanceTests.cs | 184 +++++ .../KeyboardConfigLiveMountProbeTests.cs | 13 +- .../UI/Layout/MapHousePanelControllerTests.cs | 16 + .../UI/Layout/OptionsPanelControllerTests.cs | 16 + .../UI/Layout/PaperdollControllerTests.cs | 14 +- .../UI/Layout/RetailDialogFactoryTests.cs | 86 +- .../UI/Layout/RetailKeyNamesTests.cs | 33 +- .../Layout/SelectedObjectControllerTests.cs | 25 +- .../Layout/SpellcastingShortcutInputTests.cs | 47 ++ .../UI/Layout/ToolbarInputControllerTests.cs | 30 + .../UI/Layout/VendorUiControllerTests.cs | 227 +++++- .../UI/RetailUiInteractionFlowTests.cs | 35 +- .../AcDream.App.Tests/UI/UiRootInputTests.cs | 38 + .../Messages/ClientCommandRequestsTests.cs | 1 + .../Messages/ServerMessageTests.cs | 16 + .../WorldSessionChatTests.cs | 12 + .../Chat/ChatCommandTargetStateTests.cs | 18 + .../Chat/InventoryFailureMessagesTests.cs | 6 + .../RetailActionIdentityRoundTripTests.cs | 119 +-- .../Input/RetailActionMapReaderTests.cs | 39 + .../Items/ExternalContainerStateTests.cs | 34 + .../InventoryContainerPlacementPolicyTests.cs | 76 ++ .../Items/ItemInteractionPolicyTests.cs | 55 +- .../Items/VendorStagingListTests.cs | 22 + .../Chat/LiveChatCommandRouteTests.cs | 65 ++ .../Chat/RetailPublicChatParserTests.cs | 35 + .../Gameplay/RuntimeCombatAttackStateTests.cs | 2 + .../Gameplay/RuntimeInventoryStateTests.cs | 20 + .../RuntimeLocalPlayerMovementStateTests.cs | 101 +++ .../Input/InputDispatcherCaptureTests.cs | 41 +- .../Input/InputDispatcherTests.cs | 40 + .../Input/KeyBindingsJsonTests.cs | 53 +- .../Input/KeyBindingsRetailTests.cs | 25 +- tools/dump-keymap/Program.cs | 50 ++ tools/run-release-gate.ps1 | 11 +- 151 files changed, 10162 insertions(+), 1211 deletions(-) create mode 100644 docs/research/2026-08-26-combined-client-parity-gate.md create mode 100644 docs/research/2026-08-26-issues-444-445-447-test-script.md create mode 100644 docs/research/2026-08-26-retail-inventory-interaction-audit.md create mode 100644 docs/research/2026-08-26-retail-keyboard-routing-audit.md create mode 100644 src/AcDream.App/Input/RetailEmoteMotionTable.cs create mode 100644 src/AcDream.App/Input/RetailKeymapFile.cs create mode 100644 src/AcDream.App/Net/DatChatPoseCatalog.cs create mode 100644 src/AcDream.App/UI/Layout/RetailConfirmationMenuDialogView.cs create mode 100644 src/AcDream.Core/Items/InventoryContainerPlacementPolicy.cs create mode 100644 src/AcDream.Runtime/Chat/RetailPublicChatParser.cs create mode 100644 tests/AcDream.App.Tests/Input/RetailEmoteMotionTableTests.cs create mode 100644 tests/AcDream.App.Tests/Input/RetailKeymapFileTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/KeyboardConfigInstalledDatConformanceTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/SpellcastingShortcutInputTests.cs create mode 100644 tests/AcDream.Core.Tests/Items/InventoryContainerPlacementPolicyTests.cs create mode 100644 tests/AcDream.Runtime.Tests/Chat/RetailPublicChatParserTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 5bb2231a..199cd8da 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,9 +24,251 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #450 — Fast character re-entry after logout can remain in portal space at `lb 0/0` + +**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate. +**Component:** session reset / streaming-origin retirement / login reveal. + +After Shift+Escape logout to character selection, immediately entering the +character again could leave the client indefinitely in portal space with no +landblocks admitted. The server accepted the second entry; the client title +remained at `lb 0/0`. + +**Root cause/fix:** confirmed logout starts a frame-budgeted retirement of the +old streaming window, but the synchronous session reset ignored its incomplete +result and exposed the fresh Runtime generation. The new world then inherited +the old origin-recenter admission gate. The confirmed-logoff pump now holds the +authored tunnel until old-window retirement converges, then transfers that +completed barrier through the reset callback exactly once. A deterministic +regression proves the character-select handoff cannot execute while retirement +is incomplete and cannot begin a duplicate retirement during reset. + +**Acceptance:** Shift+Escape to character selection, immediately re-enter, and +confirm the destination begins admitting landblocks and exits portal space. +Repeat twice in one process. + +## #449 — Main backpack remains falsely full after an item slot is freed + +**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate. +**Component:** inventory drag acceptance / main-pack capacity. + +With a full backpack, a move into it correctly shows the red reject cursor. +After dropping an item to free a slot, later moves from another pack could +remain rejected. + +**Root cause/fix:** main-pack fullness counted every child of the player, +including side bags, even though retail places side bags in a separate +container-selector list governed by `ContainersCapacity`. Capacity fill, +append placement, and drag acceptance now count only visible loose contents. +A regression starts with two loose items plus a side bag at capacity two, +removes one loose item, and proves the next drag changes from Reject to Accept +with a 50% capacity meter. + +**Acceptance:** fill the main pack, observe one rejected move, drop one loose +item, then move an item from a side pack into the freed main-pack slot. It must +accept immediately without reopening the inventory window. Run with the +combined gate in `docs/research/2026-08-26-combined-client-parity-gate.md`. + +## #448 — Outgoing melee hit messages expose a percentage that retail does not print + +**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate. +**Component:** combat chat / AttackerNotification presentation. + +Successful outgoing melee hits currently print a percentage in chat, for +example `You hit ... for ... damage (54.0%).` The owner reports that this is +not retail behavior and that the percentage should not be shown. + +**Likely seam:** `CombatChatTranslator.HandleDamageDealt` unconditionally +appends `DamageDealt.DamagePercent`; its tests explicitly pin a template taken +from holtburger rather than the named retail client. Recover the exact +AttackerNotification presentation from the named retail decomp/string tables, +then replace the formatter and its tests. Preserve the wire value in combat +state if it has another legitimate consumer; this issue concerns chat output. + +**Acceptance:** ordinary and critical outgoing melee hit lines match retail +wording and punctuation exactly and contain no acdream-added percentage. + +## #447 — `@acecommands` produces blank lines in the chat window + +**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate. +**Component:** ACE server-command responses / chat presentation. + +Running `@acecommands` against the test server produces a series of blank +chat lines instead of the command names and descriptions. The command reaches +ACE, but its multiline response loses its visible text before presentation. + +**Investigation seam:** capture the authoritative response message type and +raw payload, then trace it through the server-command/interface-text parser, +`RuntimeCommunicationState`, and retained chat markup rendering. Do not work +around the defect by printing the static `docs/reference/ace-commands.md` +copy; the live server response must render correctly. + +**Acceptance:** `@acecommands` displays every non-empty server response line +inside retail's retained transcript window with its text intact (newest +complete-line tail when the response itself exceeds the cap), produces no +blank-line spam, and does not regress normal chat or other ACE commands. + +**2026-08-26 fix:** ACE sends the complete command listing as one `0xF7E0` +`ServerMessage` containing embedded newlines. The parser and runtime route +already preserved that payload. The retained transcript budget treated the +whole message as one indivisible log entry, however, so an admin-sized reply +larger than retail's `0x2710`-character cap advanced past the only entry and +rendered nothing. `ChatTranscriptRenderer` now clips an oversized boundary +entry at a newline and keeps its newest complete lines, matching retail's +front-truncation behavior. Ordinary multiline replies below the cap render +every authored line. Parser round-trip, normal multiline, oversized response, +filter, tagged-run, and existing chat regression tests pass. Owner check is in +`docs/research/2026-08-26-combined-client-parity-gate.md`. + +## #446 — Configure Keyboard bindings need an end-to-end retail-parity pass + +**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate, +including connected behavior and persistence. +**Component:** input / Configure Keyboard / binding persistence. + +The owner reports that keyboard binding still does not work reliably or match +retail. Treat this as an end-to-end product gate rather than another isolated +layout fix: display the authored mappings, capture a replacement key or mouse +button, apply the correct retail conflict rules, make the new action fire, +and preserve it across restart. Escape cancellation, Reset/Defaults, scoped +combat bindings, modifier chords, and mouse bindings must also match retail. + +Existing issue #373 is one known concrete defect in this flow: acdream ignores +the DAT `ActionMap.ConflictingMaps` table and can erase valid shared combat +bindings. The fixes recorded under #394-#396 remain pending a complete owner +re-gate and do not establish that binding works end to end. + +**2026-08-26 implementation:** deep audit at +`docs/research/2026-08-26-retail-keyboard-routing-audit.md`. Bare Escape no +longer exits player mode or exposes the orbit/developer bird's-eye camera. It +now follows the complete proven retail ladder: finish jump charge, release +focused UI, stop movement/repeat attack, cancel target mode, clear selection, +then toggle the authored Gameplay Options page. Shift+Escape reaches the real +logout gate. + +All 306 installed ActionMap rows now have distinct live identities and enabled +Configure Keyboard rows. Exact defaults, contexts, activation, DAT conflict +policy, modifier-only and mouse capture, duplicate-chord multicast, explicit +unbinding, dense two-slot insertion, same-row no-op, unsupported-input retry, +priority conflict/non-bindable dialogs with exact DAT text, dirty-only Revert, +Apply/Defaults/OK/Cancel, schema migration, and persistence are implemented. +The complete camera, selection, missile, magic, 87-emote, screenshot/help/ +plugin, quickslot 1–18, panel/chat, and 48 CharacterSettings families reach +concrete consumers. Selection includes retail radar/combat/fellow/vendor/ +environment and session opened-corpse rules. The approved 40 m mouse-wheel +chase zoom remains unchanged and regression-pinned. + +Retail's Load File / Save As path is now live as well: the client parses and +writes the Sept-2013 PFile `.keymap` grammar under +`Documents\Asheron's Call`, remembers the selected profile, presents the +authored type-7 file menu and type-5 filename/overwrite dialogs, loads it on +startup, and rewrites it on graceful shutdown like retail. `keybinds.json` +remains a compatibility mirror for acdream-only commands. AP-202 is retired. + +Automated keyboard-impact evidence is green: App 6,413/6,413, Core +4,713/4,713, Runtime 1,849/1,849, and UI.Abstractions 879/879 (13,854 +tests total). Installed-DAT conformance pins all 306 identities, defaults, the +authored Configure Keyboard mount, and active Load/Save controls. Only the +connected gate below remains. + +**First owner-round findings fixed 2026-08-26:** modifier-only capture now +normalizes LeftShift and consistently raises the retail overwrite prompt when +Move Forward conflicts with Toggle Walk/Run. Regular Enter enters chat without +its raw event immediately submitting the new field; keypad Enter no longer +falls through to the raw chat-focus shortcut. Melee height keys now preserve +the Press→held charge→Release transaction instead of treating the first Hold +tick as release. Map mode transforms both retail's target direction and viewer +offset through the target frame, placing the eye high overhead rather than low +behind the character; the approved mouse-wheel zoom range is unchanged. +Shift+Escape's same-process relog portal stall is tracked and fixed as #450. +Focused App coverage plus the standard Release lane pass. + +**Acceptance:** a connected retail side-by-side covers representative movement, +combat, panel, modifier, and mouse mappings; every rebound action executes, +conflicts match retail, cancellation changes nothing, and applied bindings +survive a fresh client launch. + +## #445 — Stack split errors in inventory; vendor drag ignores selected quantity + +**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate. +**Component:** inventory stack splitting / vendor sell staging / shared split +quantity. + +Two live paths fail after selecting a partial quantity with the stack slider: + +1. Splitting a stack within the inventory produces an error instead of moving + the selected quantity into the destination slot. +2. With a stack of 10 and the slider set to 2, dragging the stack into the + vendor window stages all 10 rather than the selected 2. + +**Expected:** the selected quantity is the single shared value consumed by +inventory split operations and by the vendor drop path; the source retains +the remainder. Capture the exact inventory error text/code during the fix +gate. + +**Investigation seam:** trace `StackSplitQuantityState` from selection/slider +changes through the inventory `SendStackableSplitToContainer` request. The +vendor path currently documents and implements full-stack sell staging in +`VendorUiController.EvaluateSellAcceptability`; compare that claim against +named retail and a retail client gate before changing it, then make the +observed behavior and documentation agree. This is distinct from #313, which +only tracks selection transfer to the newly created split result. + +**2026-08-26 fix:** named retail's enclosing +`VendorSellUI::AcceptDragObject @ 0x004C4F00` disproved the old full-stack-only +comment. A partial vendor drop now sends the exact slider quantity through the +canonical inventory transaction owner, stages the source as retail's temporary +row, and replaces that row in place when the server-created stack with matching +WCID/quantity arrives. A matching failure removes the placeholder. Ordinary +inventory splitting now uses that same owner and computes empty main-pack +placement from visible loose items, excluding side bags that live in retail's +separate selector list. Exact quantity, request lifetime, replacement order, +and side-bag placement are regression-tested. Owner check is in +`docs/research/2026-08-26-combined-client-parity-gate.md`. + +## #444 — Vendor alternate-currency balance stays stale after a successful purchase + +**Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate. +**Component:** vendor UI / alternate-currency purchase refresh. + +At a vendor that accepts an alternate currency (observed with Colosseum +Coins), the purchase succeeds and the server removes the currency, but the +vendor window continues to show the pre-purchase holding. Example: the purse +line says "You have 10 Colosseum Coins" before the purchase and still says 10 +afterward. The displayed holding should update immediately after the +authoritative purchase/inventory update. + +**Likely seam:** `VendorUiController.BuildPurseText` and `BuildCostText` read +the vendor-open snapshot `VendorShopProfile.AlternateCurrencyAmount` +directly. `OnObjectMoneyChanged` repaints the text, but the repainted value is +still that latched profile amount rather than the live alternate-currency +holding (or retail's `trade_num - m_last_sale` equivalent). Add a connected +regression for purchase success followed by the refreshed purse and item-cost +text; cover both the Buying tab and Items tab. + +**2026-08-26 fix:** alternate-currency displays and Buy All affordability now +prefer the authoritative sum of player-owned currency stacks. On a successful +Buy/Buy All dispatch, retail's `m_last_sale` subtraction updates the Items and +Buying/Selling purse text immediately; the next matching currency add/update/ +move/remove clears that optimistic subtraction and repaints from canonical +inventory. The vendor snapshot remains only the pre-observation fallback. +Automated coverage pins the immediate 10→8 display and the subsequent +authoritative 8→8 reconciliation. Owner check is in +`docs/research/2026-08-26-combined-client-parity-gate.md`. + ## #443 — Examination/paperdoll private viewport: doll appears only after a delay on first open (was: "renders nothing") -**Status:** FIXED / OWNER-ACCEPTED 2026-08-25. +**Status:** FIXED / CONNECTED LIVE RE-GATE PASSED 2026-08-26 — awaiting owner +acceptance. Reopened after the owner again observed a missing paperdoll that +appeared only after waiting. Previously marked FIXED / OWNER-ACCEPTED +2026-08-25. The recurrence exposed two remaining gaps: palette/clothing texture +composites could still be pending when the private pass cleared and published +its target, and Vulkan's two concurrently recorded frames reused that same +offscreen image as both a color attachment and a retained-UI sampled texture. +The 2026-08-26 combined client-parity gate passed every #444–#450 row on the +same Release binary while the paperdoll remained missing, confirming #443 is +an isolated private-viewport defect rather than an inventory transaction, +input, relog, chat, combat-text, or vendor failure. **Component:** private entity viewports (examination clone, inventory paperdoll — shared `PrivateEntityViewportRenderer`). **Filed:** 2026-08-25, AS-GF1 gate-fix session. **Narrowed same day at the @@ -59,6 +301,25 @@ and new residency tests pass 30/30; the App hermetic lane passes 6,358/6,358. The owner then live-verified repeated inventory and monster/player assessment opens against the local ACE test server: "Good. works." +**2026-08-26 recurrence fix:** the shared renderer now advances and gates the +complete private-entity resource set — mesh upload plus original, palette and +clothing-composite textures — before allocating, clearing, or publishing a +new viewport target. It therefore keeps the previous completed image (or the +authored panel art on first use) until the new doll is actually drawable. +`PaperdollFramePresenter` also builds, redresses and prewarms the inventory +doll while its tab is hidden, so opening the tab no longer starts residency +work from zero. The decisive intermittent fault was the shared render target: +one Vulkan flight slot could clear/write it while the other still sampled it. +`PrivateEntityViewportRenderer` now owns a bounded target, sampler and texture +slot per encountered GPU flight slot, and publishes the current frame's exact +handle. The same correction covers inventory paperdoll, creature appraisal and +character-creation preview viewports. Temporary flight-slot colors proved both +slots render the complete textured doll; all probes were then removed. The +clean Release client passed first open plus two repeated close/reopen cycles on +the local ACE server with no missing frame and no runtime error. Focused App, +Runtime and input tests pass 384/384, including the byte-exact production +SPIR-V oracle; the Release solution builds with zero warnings/errors. + Owner report at the Campaign AS connected gate: the animated 3-D paperdoll in the examination window (LayoutDesc `0x2100006B` element `0x10000148`) worked correctly at baseline `974fe88a` (praised the same session) and was @@ -4004,8 +4265,7 @@ switching stays #376. ## #373 — Configure Keyboard: DAT `ActionMap.ConflictingMaps` not consulted — the combat cluster raises false conflict prompts -**Status:** OPEN — filed 2026-08-11 at Campaign OP slice OP8's re-review -round 2 (R1's scope boundary). +**Status:** DONE 2026-08-26 — fixed as the first #446 keyboard-parity slice. The DAT ActionMap (DID `0x26000000`) carries a `ConflictingMaps` table retail's `UIOption_ActionKeyMap` consults when deciding whether two rows @@ -4022,17 +4282,22 @@ new action to one) prompts "overwrite N bindings?" where retail prompts for fewer or none. Accepting the prompt then strips retail-default bindings that should have survived. -The OP8 round-2 fix already excluded store-only rows (`MappedAction is -null`) from the conflict universe — those cannot collide because they -never reach the InputDispatcher — but retail-mapped cross-context -sharing needs the real table. **Fix:** parse `ConflictingMaps` in +The OP8 round-2 fix originally excluded store-only rows (`MappedAction is +null`) from the conflict universe. Campaign KB later mapped and enabled every +one of the 306 installed rows, eliminating that tier; retail cross-context +sharing still needs the real table. **Fix:** parse `ConflictingMaps` in `RetailActionMap` (the reader already round-trips the field — `RetailActionMapReaderTests` constructs it), and make `FindConflicts` consult it: two rows sharing a chord conflict only if their contexts' ConflictingMaps entries say so. Conformance-test against the combat cluster's authored defaults (five keys, multi-row each, zero prompts on -a no-op rebind). The gate script's §OP8 warns the user off treating the -false prompts as new breakage until this lands. +a no-op rebind). + +**Fix landed:** `RetailActionMapSnapshot` now owns the copied DAT conflict +sets and `KeyboardConfigController.FindConflicts` consults them before +offering reassignment. Hermetic tests pin permitted cross-combat sharing and +declared cross-map conflicts; an installed-DAT test pins that melee, missile, +and magic are pairwise non-conflicting. Same-context conflicts remain active. ## #372 — Options panel: Character/Chat/Config tabs render BLANK on screen and most Gameplay buttons do nothing (connected-gate failure) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 1c82befa..0fec343b 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -1,4 +1,4 @@ -# Retail Divergence Register — current through 2026-08-22 +# Retail Divergence Register — current through 2026-08-26 **What this is.** The single auditable register of every known place acdream's runtime behavior can deviate from the retail client (Sept 2013 EoR build, @@ -215,12 +215,33 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 164 active rows (AP-235 filed 2026-08-25 at the Campaign CT4 fix round — `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# switches instead of a live `EnumMapper` read; AP-234 filed 2026-08-23 at the #426 solid-face extraction fix — cell-wall geometry keeps approximating retail's "skip untextured subsets in a cell" with the polygon's NoPos flag rather than the Surface's own Type; AP-233 filed 2026-08-23 at the Holtburg windmill fix — the render-side inter-frame animation blend, now holding the boundary frame at every seam; AP-232 filed 2026-08-22 at Campaign VM VM1 — the #226 two-draw detail blend weight on TRANSLUCENT subsets versus retail's single stage-1 output alpha; AP-185 RETIRED 2026-08-20 — `RetailWindowLockPresentationController` now swaps all eight imported locked/live chrome blocks, hides live-only floating-chat and SmartBox grips, suppresses only the nine-slice grip overlay, and applies the current lock before a late-mounted window's first `OnShown`; the radar's persistent B7/B8 semantic face is pinned against pointer-state clobber and covered by a real-fixture draw cycle; AP-231 filed 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the Skills page formula-connector-text approximation in `ComposeFormula`, see the row's own text for the full disclosure of what is byte-verified versus best-derived; AP-213 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the remaining flat-list-vs-four-bucket-sorted-model half is now ported: `ChargenSkillDetail`/`ChargenSkillFormula` (Core) thread `SkillBase.MinLevel`/`Description`/`Formula` from the global SkillTable through `ChargenOptions.TryGetSkillDetail` (`ChargenTableReader.Project` populates it, live-DAT-pinned at 38 entries — 23 MinLevel<=1/15 MinLevel==2, matching the Batch F investigation's own recorded finding exactly), and `CharacterCreationSkillsPage` now groups every costable skill into `SkillBucket` (Specialized/Trained/UseableUntrained/UnuseableUntrained, `UpdateSkillEntry`'s own `iMinlevel <= 1` test), sorts each bucket alphabetically (`InsertEntrySorted`'s `wcscmp`, ported as `string.CompareOrdinal`), and builds one `Templates[0]` header row per bucket ahead of that bucket's `Templates[1]` skill rows — `DoSkillRecords`'s own unconditional 4-header-then-populate build order. A level change re-buckets the row (detected per-refresh against each row's own cached bucket, then a full rebuild — the observable placement matches retail's incremental single-row `InsertEntrySorted` move without reproducing its internal mechanism, a documented and harmless substitution). 3 new fixture tests (`SkillsPage_BucketHeaders_AlwaysBuildAllFour_InRetailOrder`, `SkillsPage_UntrainedSkill_BucketsByMinLevel`, `SkillsPage_AdvancingASkill_MovesItsRowIntoTheNewBucket`) plus 1 new live-DAT test (`InstalledSkillTable_GlobalSkillDetails_MinLevelDistributionMatchesCostCoverage`); AP-216/AP-217 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 1 — both rows' STOPPED items are now landed: `CharacterCreationUiController.AppearancePalSetSource`/`AppearanceClothingTableSource`/`AppearancePaletteColorSource` wire a DAT-backed `ChargenAppearanceCatalog` into the Appearance page from `LivePresentationComposition` (mirroring the existing `AppearancePreviewControl` seam), and `UiButton`/`UiDatElement` both gained a per-instance `Tint` property threaded into every existing `DrawSprite` call they make; `CharacterCreationAppearancePage` now sets `Tint` directly on each swatch button and the GradCircle element instead of layering a flat-fill `ChargenSwatchColorTile` overlay on top (that class is deleted) — a genuine multiplicative sprite tint on the widget's OWN authored art, matching retail's `SurfaceWindow::BlitAndColor(..., Blit_Multiply, color)` exactly rather than approximating it with an opaque rectangle. Both fixture test suites (`CharacterCreationAppearancePageSwatchColorTests`, 8 tests) and the live-DAT color pins (`ChargenAppearanceCatalogColorTests`) pass unchanged against the new mechanism; AP-218 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-6) — `gmCGAppearancePage::Update`'s heritage-flavored static Hair/Eyes/Skin spin caption (`ID_CharGen_HairStyle`/`_Eyes`/`_Skin`, Gearknight `GearText_*`, Olthoi/OlthoiAcid `OlthoiText_*`) is now ported verbatim by `RefreshSpinCaptions`, replacing the prior ordinal substitution outright — see AP-215's own rewritten row for what remains open (the icon-thumbnail gap, restated); recount at this same edit: the row count this header carried before Batch B was already one LOW relative to the physical table (Batch A's own ending state: header said 164, the physical table already held 165 rows — verified by direct count against that commit) — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change (F12 correction, gate round 1 closeout, 2026-08-16: this note originally said "one high", the inverted direction — the header was UNDER-counting, not over-counting); AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; ~~AP-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 162 active rows (AP-202 RETIRED 2026-08-26 by #446 — retail PFile `.keymap` Load/Save/startup/shutdown persistence is now live; AP-235 filed 2026-08-25 at the Campaign CT4 fix round — `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# switches instead of a live `EnumMapper` read; AP-234 filed 2026-08-23 at the #426 solid-face extraction fix — cell-wall geometry keeps approximating retail's "skip untextured subsets in a cell" with the polygon's NoPos flag rather than the Surface's own Type; AP-233 filed 2026-08-23 at the Holtburg windmill fix — the render-side inter-frame animation blend, now holding the boundary frame at every seam; AP-232 filed 2026-08-22 at Campaign VM VM1 — the #226 two-draw detail blend weight on TRANSLUCENT subsets versus retail's single stage-1 output alpha; AP-185 RETIRED 2026-08-20 — `RetailWindowLockPresentationController` now swaps all eight imported locked/live chrome blocks, hides live-only floating-chat and SmartBox grips, suppresses only the nine-slice grip overlay, and applies the current lock before a late-mounted window's first `OnShown`; the radar's persistent B7/B8 semantic face is pinned against pointer-state clobber and covered by a real-fixture draw cycle; AP-231 filed 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the Skills page formula-connector-text approximation in `ComposeFormula`, see the row's own text for the full disclosure of what is byte-verified versus best-derived; AP-213 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the remaining flat-list-vs-four-bucket-sorted-model half is now ported: `ChargenSkillDetail`/`ChargenSkillFormula` (Core) thread `SkillBase.MinLevel`/`Description`/`Formula` from the global SkillTable through `ChargenOptions.TryGetSkillDetail` (`ChargenTableReader.Project` populates it, live-DAT-pinned at 38 entries — 23 MinLevel<=1/15 MinLevel==2, matching the Batch F investigation's own recorded finding exactly), and `CharacterCreationSkillsPage` now groups every costable skill into `SkillBucket` (Specialized/Trained/UseableUntrained/UnuseableUntrained, `UpdateSkillEntry`'s own `iMinlevel <= 1` test), sorts each bucket alphabetically (`InsertEntrySorted`'s `wcscmp`, ported as `string.CompareOrdinal`), and builds one `Templates[0]` header row per bucket ahead of that bucket's `Templates[1]` skill rows — `DoSkillRecords`'s own unconditional 4-header-then-populate build order. A level change re-buckets the row (detected per-refresh against each row's own cached bucket, then a full rebuild — the observable placement matches retail's incremental single-row `InsertEntrySorted` move without reproducing its internal mechanism, a documented and harmless substitution). 3 new fixture tests (`SkillsPage_BucketHeaders_AlwaysBuildAllFour_InRetailOrder`, `SkillsPage_UntrainedSkill_BucketsByMinLevel`, `SkillsPage_AdvancingASkill_MovesItsRowIntoTheNewBucket`) plus 1 new live-DAT test (`InstalledSkillTable_GlobalSkillDetails_MinLevelDistributionMatchesCostCoverage`); AP-216/AP-217 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 1 — both rows' STOPPED items are now landed: `CharacterCreationUiController.AppearancePalSetSource`/`AppearanceClothingTableSource`/`AppearancePaletteColorSource` wire a DAT-backed `ChargenAppearanceCatalog` into the Appearance page from `LivePresentationComposition` (mirroring the existing `AppearancePreviewControl` seam), and `UiButton`/`UiDatElement` both gained a per-instance `Tint` property threaded into every existing `DrawSprite` call they make; `CharacterCreationAppearancePage` now sets `Tint` directly on each swatch button and the GradCircle element instead of layering a flat-fill `ChargenSwatchColorTile` overlay on top (that class is deleted) — a genuine multiplicative sprite tint on the widget's OWN authored art, matching retail's `SurfaceWindow::BlitAndColor(..., Blit_Multiply, color)` exactly rather than approximating it with an opaque rectangle. Both fixture test suites (`CharacterCreationAppearancePageSwatchColorTests`, 8 tests) and the live-DAT color pins (`ChargenAppearanceCatalogColorTests`) pass unchanged against the new mechanism; AP-218 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-6) — `gmCGAppearancePage::Update`'s heritage-flavored static Hair/Eyes/Skin spin caption (`ID_CharGen_HairStyle`/`_Eyes`/`_Skin`, Gearknight `GearText_*`, Olthoi/OlthoiAcid `OlthoiText_*`) is now ported verbatim by `RefreshSpinCaptions`, replacing the prior ordinal substitution outright — see AP-215's own rewritten row for what remains open (the icon-thumbnail gap, restated); recount at this same edit: the row count this header carried before Batch B was already one LOW relative to the physical table (Batch A's own ending state: header said 164, the physical table already held 165 rows — verified by direct count against that commit) — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change (F12 correction, gate round 1 closeout, 2026-08-16: this note originally said "one high", the inverted direction — the header was UNDER-counting, not over-counting); AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; AP-203 RETIRED 2026-08-26 by #446 — all 306 installed-DAT rows now have distinct identities and concrete consumers; AP-204 corrected and RETIRED the same day — exact capture/conflict/button semantics now follow named retail; AP-202 RETIRED 2026-08-26 by #446 — retail `.keymap` file interchange and profile lifetime now ship; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered AP-94..AP-112 for the confirmed retail-UI completion gaps. +**AP-161 correction and narrowing (2026-08-26, #444/#445):** the old F6 +full-stack conclusion and the row's `m_last_sale` conclusion are superseded. +`VendorSellUI::AcceptDragObject @ 0x004C4F00` splits the live selected +quantity, temporarily stages the source, then substitutes the new matching +WCID/quantity object; acdream now ports that flow through the canonical +inventory owner. `gmVendorUI::BuySingleItem @ 0x004C2820` and Buy All both +assign the purchase value to `m_last_sale`; purse/cost/affordability now +subtract it immediately and reconcile to authoritative owned-currency +objects. Those two residuals are closed. The closed-dropdown arrow-cap +cosmetic is AP-161's only live item; the long row below remains as historical +research chronology and must be read through this correction. + +**Vendor double-click correction (2026-08-26):** direct reading of +`gmVendorUI::HandleMousePresses @ 0x004C40D0` disproved the earlier +absence-of-symbol inference embedded in AP-161 and AP-171. Retail directly +buys a browse row on double-click and removes staged Buying/Selling rows in +that same mouse dispatcher. AP-171 is retired; the old AP-161 sentence saying +double-click-to-buy is absent is superseded by this correction. The active-row +count in the heading is therefore one lower than the retained historical +heading text. + | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | AP-235 | **Filed 2026-08-25 at the Campaign CT4 fix round.** Retail resolves gender display text via `AppraisalSystem::InqGenderDisplayName @0x005b47c0` and heritage via `InqHeritageGroupDisplayName @0x005b4710`, both through the static `EnumMapper::GetString(uint32_t enumValue, uint32_t queryId, PStringBase*) @0x0041ac40` overload — `DBObj::GetDIDByEnum(&did, enumValue, 1)` (master map `0x25000000` → category-1 sub-map `0x25000001` → `ClientEnumToID[0x10000001]`/`[0x10000002]` → EnumMapper DIDs `0x2200000A`/`0x2200000B`) — reading each id's `IdToStringMap` entry live, with heritage ids 2/5/0xd hardcoded to `"Gharu'ndim"`/`"Umbraen"`/`"Olthoi"` in place of the raw internal names `"Gharundim"`/`"Shadowbound"`/`"OlthoiAcid"`. `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# `switch` tables instead — a mechanism divergence (compile-time constant vs. live DAT read), not a content one: `CharacterPanelLiveDatTests.GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain` (filed the same round) walks the live EnumMapper chain and asserts every table entry byte-exact, including the two entries (10 "Penumbraen", 12 "Olthoi") the CT4 review had flagged as unverified guesses — both are correct. | `src/AcDream.App/UI/Layout/CharacterIdentityText.cs` (`GenderDisplayName`, `HeritageGroupDisplayName`); `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs` (`ResolveHeritage` — CT5 fix round 2026-08-25 deleted its independent re-implementation of the same 2/5/13 overrides; it now delegates straight to `CharacterIdentityText.HeritageGroupDisplayName`, so this row's divergence has exactly ONE owner, not two) | `RetailDataIdResolver.Resolve` (`src/AcDream.Content/RetailDataIdResolver.cs`) already ports the generic two-level `GetDIDByEnum` chain (used today for layout/material DIDs); unifying gender/heritage onto it needs only `Resolve(dats, enumValue: 0x10000001u/0x10000002u, enumCategory: 1u)` plus an `EnumMapper.IdToStringMap` read — a live-DAT-only path with no bespoke traversal code to write, which is why the tables stayed hardcoded this round rather than porting live-read on the spot; CT5 is the natural landing slot since it already owns this same DAT-lookup family for the Titles page | A future DAT/game update that renames or reorders a heritage/gender enum entry would silently desync acdream's hardcoded tables from retail's live text with no build-time or runtime signal — the CT5 fix round retired the second-copy drift risk (`ResolveHeritage` now reads the same single table), but the core hardcoded-vs-live-DAT divergence itself remains open | `AppraisalSystem::InqGenderDisplayName @ 0x005B47C0`; `InqHeritageGroupDisplayName @ 0x005B4710`; `EnumMapper::GetString @ 0x0041AC40`; `DBObj::GetDIDByEnum @ 0x004153A0` | @@ -238,9 +259,9 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-206 | **Filed 2026-08-11 at Campaign OP gate 4 (#382).** `UiButton.TrySetRetailState`'s DirectStateId branch now requires REAL `""`-keyed media (`HasStateMedia("")`) before accepting a DirectState transition; a `_mediaInfo.States` entry that exists ONLY as a property bag (every button carries one, holding ToggleBehavior/RolloverEnabled/etc regardless of whether it authors blank media) no longer counts. A reference-identity-verified live-DAT probe found the chat window's four floating-window indicator buttons (`0x10000522`-`0x10000525`) resolve their own correct `ActiveState="Normal"` at construction, then get blanked to `""` moments later in the SAME `LayoutImporter.Build` call: the indicator column's backing panel (`0x10000600`) authors `PassToChildren=true` on its own empty DirectState (confirmed live: `States[0xFFFFFFFF].PassToChildren == true`), and `LayoutImporter.BuildWidget`'s post-attach state reapply (needed so retained PassToChildren TABS get their authored Open/Closed child media) cascades that DirectState to every `IUiDatStateful` child — including these already-correctly-resolved buttons. Retail's own decompiled `UIElement::SetState @0x00464e70` commits its `m_curStateDesc`/`m_state` unconditionally once `ElementDesc::AccessStateDesc` finds ANY StateDesc (media or not) and does the exact same blind per-child cascade; retail avoids this exact bug purely through construction TIMING — `UIElement::Initialize`'s `SetState(m_defaultState)` call is the SECOND operation in the function, before any child-tree construction, so a PassToChildren cascade fired during import always iterates zero children in retail. Our port's `LayoutImporter.BuildWidget` deliberately reapplies AFTER children are attached (the opposite order), so this literal 1:1 state-machine port needed a compensating guard rather than a full reapply-ordering rewrite (out of scope for this fix; `CharacterStatController`'s own three-chrome-children PassToChildren cascade depends on the current ordering and is left untouched). | `src/AcDream.App/UI/UiButton.cs` (`TrySetRetailState`'s `stateId == UiStateInfo.DirectStateId` branch) | Scoped to `UiButton` only — `UiDatElement.TrySetRetailState`'s parallel DirectStateId branch (and the cascade mechanism itself) are UNCHANGED, so every existing PassToChildren consumer keeps its current behavior; the fix only stops an UNRELATED ancestor's cascade from overriding a button's OWN already-resolved, independently authored state with an empty one it never asked for. | If a future button is EVER meant to render literally blank at rest via a cascaded DirectState with no authored `""` media, this guard would reject that transition (falls back to its previous `ActiveState`) — no such button is known to exist today; `UiButtonTests.DirectStateTransition_WithRealMedia_StillSucceeds` documents that an AUTHORED blank state still works. | `UIElement::SetState @0x00464e70` (cascade + unconditional commit); `UIElement::Initialize @0x00462c90` (SetState call precedes child construction) — both in `docs/research/named-retail/acclient_2013_pseudo_c.txt` | | AP-205 | **Filed 2026-08-11 at Campaign OP gate 4 (#381).** The Apply/Reset/Defaults footer on the Character/Chat/Config tabs draws an opaque, borderless backing field (`UiSolidSpriteFill`, tiling `RetailChromeSprites.CenterFill` — the SAME panel-background sprite the Options window's own `UiNineSlicePanel` chrome already tiles behind everything) behind the three buttons. A live-DAT probe (scratch console app against `DatCollectionAdapter`, 2026-08-11) found retail authors NO such element: each page root (`0x100001F9`/`0x100001FF`/`0x1000050A`) has EXACTLY five children — the row ListBox, its scrollbar, and the three physical buttons — with zero direct-state media on the root itself. Scrolled row content therefore bled through visibly between/behind the buttons before this fix. | `src/AcDream.App/UI/UiSolidSpriteFill.cs`; `src/AcDream.App/UI/Layout/OptionsPanelController.cs` (`AddFooterBacking`) | Reusing the SAME sprite the rest of the window's chrome already draws keeps the synthesized field visually indistinguishable from an authored one rather than inventing a new color; the field is `ClickThrough=true` and z-ordered strictly behind every other child, so it cannot intercept input or occlude the buttons themselves. | A reviewer comparing a byte-exact retail screenshot to acdream will see one extra opaque rect retail never authors — cosmetically invisible (it exactly matches the surrounding chrome), so the only observable difference IS the fix (content no longer bleeding through). If a future page's footer strip ever needs a DIFFERENT background (a themed panel, a translucent tab), this hardcoded `CenterFill` reuse would need revisiting. | Live-DAT probe, 2026-08-11 (page-root child-count/direct-state-media dump against `client_local_English.dat`, LayoutDescs `0x21000028`/`0x21000029`/`0x2100005C`) — no retail element to cite since none exists | | ~~AP-201~~ | **RETIRED 2026-08-11 at the Campaign OP gate-3 fix round (closes #371).** UiScrollablePanel now marks ClipsChildren=true (the draw walk and hit-test both route through UiRenderContext.PushClip, which existed by retirement time) and its cull predicate keeps any INTERSECTING row visible - a straddling row renders its visible slice instead of vanishing whole. The user-observed symptom this row predicted (the Chat tab per-window filter blocks reading as MISSING at the default scroll offset, gate 3) is the exact acceptance evidence. Original filing follows for the record: filed at the OP5 review-fix round (S2), predates OP5 but was made user-visible by it. `UiTemplateListBox`'s internal row viewport (`UiScrollablePanel.LayoutScrollableChildren`) culls a child WHOLE — `child.Visible = top >= -0.5f && top + child.Height <= Height + 0.5f` — rather than clipping the visible portion of a row that straddles the viewport edge, because the UI renderer has no scissor stack. Retail's own `UIElement_ListBox`/scroll-region rendering clips partially-visible rows at the pixel boundary, same as any native scroll view. Every row in this viewport was 8-36px until Campaign OP slice OP5 added five self-sized filter blocks (12x20=240px / 13x20=260px, AP-195) to the Chat tab's ~560px viewport; a 240-260px block straddling the viewport edge at a given scroll offset now disappears ENTIRELY (a visible "pop") instead of clipping, where the pre-OP5 8-36px rows made the same all-or-nothing cull read as ordinary row-granular scrolling. | `src/AcDream.App/UI/UiScrollablePanel.cs:69` (the cull predicate); consumed by `src/AcDream.App/UI/UiTemplateListBox.cs` (`Viewport`) — the Character/Chat/Config Options-panel tabs and any other controller-built row list sharing this viewport | A scissor stack does not exist anywhere in the retained-UI renderer yet (class's own doc comment, `UiScrollablePanel.cs:8-12`, predates this row); whole-row culling is a correct, cheap stand-in for every list whose rows are small relative to the viewport, which was true for every consumer before OP5. | A tall block (any future row taller than roughly the viewport's own height, not just OP5's filter blocks) can vanish completely for a range of scroll offsets instead of showing a partial view — the OP5 gate script's own step 2 documents the exact symptom so it is not mistaken for a self-sizing regression (`docs/research/2026-08-11-campaign-op-test-script.md`). Scrolling further always restores the block whole; no data or state is lost, only the presentation pops. | No scissor-stack retail oracle needed — this is a stand-in for ordinary native clip-rect rendering every GUI toolkit (including retail's own) provides; issue #371 tracks adding a real per-row clip rect to `UiScrollablePanel` | -| AP-202 | **Filed 2026-08-11 at Campaign OP slice OP8 (D4).** Configure Keyboard persists every rebind to `keybinds.json` only. Retail's own storage is a `\Asheron's Call\.keymap` text file (`CInputManager_WIN32::SaveKeyMap @0x00686C20`, `PFileParser`), with Load-File/Save-As buttons for NAMED keymap profiles and a `keymap` key in `UserPreferences.ini` selecting which one loads at startup (research doc §5.7). D4 chose the existing, tested `keybinds.json` schema over building a second `PFileParser`-compatible text codec + named-profile management; this row's the Load File/Save As buttons on the Configure Keyboard screen (`0x10000027`/`0x10000029`) are wired but INERT. | `src/AcDream.App/UI/Layout/KeyboardConfigController.cs` (`WireScreenButtons`'s Load/Save-As no-op); `src/AcDream.UI.Abstractions/Input/KeyBindings.cs` (`SaveToFile`/`LoadOrDefault`) | `keybinds.json` already round-trips every retail action this screen can bind (identity table + the DAT-defaults conformance test), so the ONLY capability lost is exchanging `.keymap` files with a real retail client or another acdream install by named profile — a real feature gap, not a correctness gap. | A user who expects to export/import a named `.keymap` profile (e.g. to share a control layout with a retail-client friend) cannot; every rebind still works and persists locally. | `docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md` §5.7-§5.8; `CInputManager_WIN32::SaveKeyMap @0x00686C20`; `gmKeyboardUI::SaveKeymap @0x004DCF90` | -| AP-203 | **Filed 2026-08-11 at Campaign OP slice OP8.** Of the DAT ActionMap's 306 user-bindable rows, `RetailActionIdentityTable` (`src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs`) resolves roughly half to a live acdream `InputAction`; the rest render, bind, conflict-check, and persist (via `RetailUnmappedKeyBindings`, a sibling `*-unmapped.json` file) exactly like any other row, but have no live gameplay consumer to dispatch through. The two largest classes: 82 of 87 Emote rows (only Cry/Laugh/Cheer/Wave/PointState dispatch an animation today — acdream has no general emote-animation player), and all 48 CharacterSettings hotkey rows (ctx `0x10000008` — these are hotkeys for the SAME `PlayerOption`/`CharacterOptions` preference bits OP1's `CharacterOptionTable` and OP4's Character-tab checkboxes already model; wiring "press this key, flip that same server-synced bit" is a real feature, a hotkey-to-option-toggle dispatcher, that does not exist anywhere in acdream yet). Smaller residuals: Spell Slot 10-12, Quickslot 10-13 (both hit a PRE-EXISTING `InputAction` enum gap this slice did not introduce), and roughly twenty UI-panel-toggle rows for panels acdream has no analog for (Vitae/Link Status/House/Map/Character Info/the two Magic panels/...). | `src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs` (class doc has the full accounting); `src/AcDream.App/UI/Layout/KeyboardConfigController.cs` (`CurrentForUnmapped`/`SetForUnmapped`) | Guessing a mapping for an ambiguous row risks silently misrouting a rebind to the wrong gameplay action (worse than an honest "not wired yet" — the identity table's own class doc states this directly); every mapping that WAS added was cross-verified two ways (label match + DAT-default-vs-`KeyBindings.RetailDefaults()` byte match, see `RetailActionIdentityRoundTripTests`). | A user rebinds e.g. an emote or a CharacterSettings hotkey on the Configure Keyboard screen and the binding persists but has no observable in-game effect — matches retail's OWN screen shape (the row exists, is bindable) while honestly lacking retail's gameplay behavior behind it. ADDENDUM (2026-08-11, OP8 re-review round 2): this row's scope EXPLICITLY includes the ten CameraAlternateControls (InputMap 0x6) rows the M2 de-alias narrowed to store-only — a case the generic wording understated because their SIBLING rows (InputMap 0x5, the same verbs) ARE live on the same screen: the 0x6 rows display their DAT-default arrow keys (display-only seeding), persist user edits, and drive nothing; only the 0x5 scheme reaches the InputDispatcher. Store-only rows are also EXCLUDED from the conflict universe (they cannot actually collide) — mapped cross-context sharing remains ISSUES #373. | `docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md` §5.1-§5.3; live-DAT probe 2026-08-11 (306-row/six-ActionClass accounting, `RetailActionMapReaderTests`) | -| ~~AP-204~~ | **RETIRED 2026-08-11 at the OP8 rework (M3, combined review).** Originally filed for two narrowings: (1) silent auto-reassign on a cross-row conflict instead of retail's modal `OpenOverwriteBindingDialog`, and (2) OK/Cancel wired as left-click instead of retail's right-click-release gesture. (1) is FIXED — `KeyboardConfigController.BeginSlotCapture` now opens a real confirm dialog through `RetailDialogFactory.MakeConfirmation` (the SAME seam `GameplayConfirmationController` uses) BEFORE reassigning, listing every conflicting row (N-way), and only applies on accept; decline leaves every row untouched. (2) is NOT fixed and does not warrant its own row: it is authored-input-only with zero observable difference to a user (retail's own right-click-release on just this pair of buttons carries no distinguishing visual cue either, and every other Campaign OP button already uses left-click) — noted as a code comment at the OK/Cancel wiring site instead of a register row, matching this register's convention of reserving rows for divergences that could produce an observable symptom. | `src/AcDream.App/UI/Layout/KeyboardConfigController.cs` (`FindConflicts`/`BeginSlotCapture`; `WireScreenButtons`'s OK/Cancel `OnClick` comment); `src/AcDream.App/UI/RetailUiRuntime.cs` (`MountKeyboardConfig`'s `ConfirmOverwrite` wiring) | — | — | `docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md` §5.4 (`UIOption_ActionKeyMap::KeyHitHandler @0x00489570`, `OpenOverwriteBindingDialog @0x00488BF0`, `OpenCantOverwriteBindingDialog @0x00489300`) and §5.5 (OK/Cancel `idMessage 0x19` gesture) | +| ~~AP-202~~ | ~~**Keyboard .keymap file import/export remains intentionally deferred.**~~ **RETIRED 2026-08-26 by #446.** Load File and Save As now use retail's authored type-7/type-5 dialogs and the Sept-2013 PFile grammar; profiles live under `Documents\Asheron's Call`, the selected profile loads at startup and rewrites on graceful shutdown, overwrite/read-only handling is live, and all 306 user-bindable identities round-trip. `%LOCALAPPDATA%\acdream\keybinds.json` remains only the host-command compatibility mirror. | ✅ RETIRED — codec, profile-store, controller, and installed-DAT gates landed. | +| ~~AP-203~~ | ~~**Configure Keyboard exposed rows without production consumers.**~~ **RETIRED 2026-08-26 by #446.** All 306 installed-DAT ActionMap rows resolve to distinct `InputAction` identities and concrete production consumers; the compatibility sibling store is retained only for unknown future-DAT rows. The installed-DAT identity, default, mount, and routing gates pin 306/306. | ✅ RETIRED — evidence: `docs/research/2026-08-26-retail-keyboard-routing-audit.md`. | +| ~~AP-204~~ | ~~**Configure Keyboard capture/conflict behavior diverged from retail.**~~ **RETIRED and corrected 2026-08-26 by #446.** Capture instructions, unsupported-input retry, same-row no-op, dense two-slot insertion, exact priority conflict/non-bindable dialogs, overwrite behavior, dirty-only Revert state, Defaults, Apply/OK, Cancel, and persistence now follow the named retail routines. `idMessage 0x19, dwParam1=7` is the authored button action/release event; the decomp supplies no right-click evidence. | ✅ RETIRED — named-retail conformance and controller tests landed. | | AP-194 | `CharacterOptionTable`'s `ClientDefault` column (what the Character tab's Defaults button restores) disagrees with the raw constructor default word for three ids: `ConfirmVolatileRareUse` (`0x2D`), `ShowHelm` (`0x2F`), and `ShowCloak` (`0x32`) are all ON in retail's constructor default `CharacterOptions2 = 0x00948700` (`PlayerModule::PlayerModule @0x005D51F0`, byte-verified literal write) but report default-OFF via `PlayerModule::GetDefaultOptionValue @0x005D2A30`, whose own per-option table stops at id `0x2A` and returns `false` for everything past it. This is retail's OWN behavior, reproduced deliberately — the Defaults button does not reproduce a fresh `PlayerModule`. **CONFIRMED 2026-08-11 at Campaign OP slice OP4**: `CharacterOptionsPageController` seeds every `BoolOptionRow`'s default directly from this column (`EveryRow_DefaultValue_MatchesCharacterOptionTableClientDefault`, `tests/AcDream.App.Tests/UI/Layout/CharacterOptionsPageControllerTests.cs`); the directive below was followed, not re-litigated. OP4 also independently traced retail's OWN mechanism for the Character tab specifically — `UIOption_Checkbox::SetPlayerOption @0x00486e80` (pseudo-C line 147375) sets `m_default` directly from `GetDefaultOptionValue`, confirming this column (not the separate `DBPropertyCollection`/`InqDefaultGameplayOptionProperty` mechanism that governs the Chat/Config tabs' `m_propName`-bound rows) is the correct and ONLY source for this tab. | `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` (`ClientDefault` column; see the type's XML doc); `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs` | Byte-verified at both addresses (wire research §2.5 for the constructor literals, §8.2 for `GetDefaultOptionValue`'s own table and bounds check) — this is not a guess, it is retail's documented quirk. "Fixing" it to match the constructor default would make acdream's Defaults button MORE correct than retail's own, which is the opposite of this project's goal. | A future OP-campaign slice (OP4, the Character tab's Defaults button) must consult THIS column, not the constructor default word, or a future reader may "fix" this back and silently diverge from retail. | `PlayerModule::GetDefaultOptionValue @0x005D2A30`; `UIOption_Checkbox::SetPlayerOption @0x00486e80` (N-4 anchor-column correction, OP4 review-fix round 2026-08-11 — was mislabeled `PlayerModule::SetPlayerOption`, same address, wrong class); `PlayerModule::PlayerModule @0x005D51F0`; `docs/research/2026-08-10-set-character-options-wire.md` §8.2 | | AP-193 | Character option id `0x34` (`ListenToPKDeathMessages` / "Listen to PK death messages") is mapped to `CharacterOptions2` bit `0x02000000` and modeled as a batched (non-auto-save) option purely on ACE's own enum — the id does not exist in the 2013 EoR PDB (`PlayerOption` there terminates at `TotalNumberOfPlayerOptions_PlayerOption = 0x34`), so neither the mask nor its `IsAutoSaveOption`/`GetDefaultOptionValue` classification is byte-verifiable against our binary. | `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` (`HearPkDeathMessages` row) | The user's retail memory (and ACE's own `CharacterOption` enum) both carry this option; shipping wire+store coverage for it is strictly better than omitting the row the Character tab's screenshots show, and ACE never actually reads the bit server-side (`PlayerFactory.cs:659-660` — "possibly was added to Defaults post PDB we have"), so a wrong id/mask/auto-save guess here has zero server-observable consequence either way. | If the final EoR client's real id/mask/auto-save classification ever surfaces (a later PDB, or a byte-level trace against a 2015+ binary), this row's values may be wrong and need correcting — until then treat them as ACE-sourced, not retail-verified. | ACE `PlayerFactory.cs:659-660`, `CharacterOptions2.cs` (`ListenToPKDeathMessages = 0x02000000`); `named-retail/acclient.h:4162-4218` (2013 `PlayerOption` terminates at `0x34`); `docs/research/2026-08-10-set-character-options-wire.md` §8.1 | | ~~AP-196~~ | **RETIRED 2026-08-11 at Campaign OP slice OP9.** Filed at the OP4 review-fix round (MUST-FIX 3/blast M2) recording that OP4's Group-C re-point deleted only three of the eight re-pointed `GameplaySettings` fields (`AutoTarget`/`AutoRepeatAttack`/`ViewCombatTarget`), leaving `VividTargetingIndicator`/`CoordinatesOnRadar`/`LockUI`/`AcceptLootPermits`/`ToggleRun` behind as WRITE-BEHIND `settings.json` persistence/draft mirrors of the now-authoritative server bit (plus a "two writable copies" default-source change, ADDENDUM historical only). OP9 verified all remaining `GameplaySettings` members — those five plus `ShowTooltips`/`SideBySideVitals`/`SpellDuration`/`AllowGive`/`ShowHelm`/`ShowCloak`/`AdvancedCombatUI`/`UseMouseTurning`, 13 total — already had a live server-bit home in `RuntimeCharacterOptionsState` (11 as OP4 Character-tab rows through `CharacterOptionTable`/`CharacterOptionsPageController`; `LockUI` through `/lockui` + the PlayerDescription `SetUiLocked` convergence, deliberately not a Character-tab row; `UseMouseTurning` through the Gameplay-tab mouse-macro button + the Config tab's Use-Mouse-Turning row — OP9 review NIT 6's channel-attribution correction) and deleted the `GameplaySettings` record outright — the type, the `SettingsStore.LoadGameplay`/`SaveGameplay` plumbing, and `RuntimeSettingsController`'s `Gameplay` property/`SetAcceptLootPermits` write-behind method — closing the "two writable copies" gap for good: there is no longer a second store to diverge from server truth. | `src/AcDream.App/Settings/RuntimeSettingsController.cs`; `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs`; `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` | — | — | `docs/research/2026-08-10-character-options-map.md` §7.1/§7.2 (Group C re-point directive); `CharacterOptionTable.cs` | @@ -372,7 +393,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-168 | **NARROWED 2026-08-08 (grand-gate finding G1) — the player's-OWN-pack half (`CountPlayerContents`) is FIXED; only the shop-stock half (`ComputeBuySlotsNeeded`) remains approximated.** Live testing surfaced the risk this row already predicted: "Buy All" false-blocked a container purchase while the player visibly had free container slots. Root cause was NOT the theoretical corner case originally described here — it was that the old dual-heuristic (`ItemType.Container` bit OR nonzero `ItemsCapacity`/`ContainersCapacity`) could over-classify an ordinary non-container object as an occupied container slot, undercounting free space. `CountPlayerContents` now reads `ClientObject.ContainerTypeHint` first — retail's actual wire `ContainerProperties` (`Item_ServerSaysContainId` 0x0022's `ContainerType`; also carried by `ContentProfile`/`PlayerDescription`'s per-entry container-kind byte), already threaded onto every owned object by `InitializeInventoryManifest`/`ApplyConfirmedServerMove`/`ReplaceContents` and already used for this identical question by `ClientObjectTable.IsContainerListMember` — falling back to `ItemType.Container` alone (the capacity-field legs were dropped) only for the rare object that never received a hint. This matches retail's real `_itemsList`/`_containersList` bucketing (`ACCWeenieObject::GetNumContainedItems`/`GetNumContainedContainers` @0x0058beb0/0x0058bec0 just report already-bucketed `IDList` lengths; the bucketing happens once, at insert time, in `ServerSaysContainID` @0x0058be40, from that same wire field) rather than reconstructing it from the item's own type/capacity fields. Original text: **Filed 2026-08-09, Opus review of `92ea3977`, finding F1 (Buy All's client pre-send capacity guard).** Retail's `gmVendorUI::InqListSlotCount` (`pc:200038-200065`, `0x004c0c10`) classifies each staged item as needing a CONTAINER slot or an ITEM slot by testing a bitfield bit (a decompiler string-misattribution artifact not yet decoded) ORed with the item's own nonzero `_itemsCapacity`/`_containersCapacity`. `VendorUiController.ComputeBuySlotsNeeded`/`CountPlayerContents` approximate this with `(item.ItemType & ItemType.Container) != 0` instead — correct for the ordinary case (an authored backpack/pouch DOES carry the `Container` type bit) but not byte-identical for the theoretical case of a non-`Container`-typed item that still authors nonzero pack/side capacities (or vice versa, a `Container`-typed item with zero capacity of its own, e.g. a locked/sealed decorative chest never meant to be carried). | `src/AcDream.App/UI/Layout/VendorUiController.cs` (`ComputeBuySlotsNeeded`, `CountPlayerContents`) | `VendorShopItem`'s wire shape (Slice 5's deliberately narrow browse-scope subset) genuinely does not carry `PublicWeenieBitfield`/`ItemsCapacity`/`ContainersCapacity`/`ContainerProperties` the way `ClientObject` does for an ordinary `CreateObject`/membership-sourced item, so `ComputeBuySlotsNeeded` (the shop-stock side, staged-but-not-yet-owned items) cannot read a wire-truth hint the way the fixed `CountPlayerContents` (the already-owned side) now does; extending the DTO was out of scope for this fix. The server remains authoritative and re-validates real pack-space regardless (`Vendor.BuyItems_ValidateTransaction`, `Vendor.cs:431-571`) — the residual failure mode stays UX/latency, not correctness. | A vendor selling a `Container`-typed item with zero authored capacity (rare/decorative) would still be misclassified as needing a container slot instead of an item slot, or vice versa for a non-`Container`-typed item that DOES author capacity (also rare) — the pre-check could still reject a purchase retail's own guard would have allowed, or allow one retail would have blocked, purely on the CLIENT side for the SHOP-STOCK item being bought; the player's-OWN-pack accounting that drives the free-slot count is no longer the source of that risk. | `gmVendorUI::InqListSlotCount` `pc:200038-200065`/`0x004c0c10`; `ACCWeenieObject::GetNumContainedItems`/`GetNumContainedContainers` `0x0058beb0`/`0x0058bec0`; `ACCWeenieObject::ServerSaysContainID` `0x0058be40`; `docs/research/2026-08-08-slice6b-vendor-completion-research.md` | | AP-169 | **Filed 2026-08-08, grand-gate finding G2 (vendor toolbar split-slider absent live). CORRECTED 2026-08-08 (re-gate finding R1). CORRECTED AGAIN 2026-08-08 (live vendor-diag evidence) — both earlier stories mis-identified the operand; this row now records the third and evidence-pinned shape.** The G2 fix fell back to the packed ItemProfile supply-count dword (unusable: a standard listing has UNLIMITED stock, `-1`). The R1 fix preferred the wire `PublicWeenieDesc::_stackSize` (`VendorShopItem.DescStackSize`) on the claim that ACE never populates it for a browse row — the live vendor-diag run REFUTED that claim: ACE serializes `descStackSize=1` for EVERY browse row (`[vendor-diag] ApproachVendor wire-item[...] descStackSize=1 stackSizeMax=100`), so desc-first resolved every vendor stack to 1 and the split bar never appeared (`ApplySelection ... failingPredicate=stackSize<=1u stackSize=1`). The named decomp settles what retail actually reads at its VENDOR-owned quantity sites: `pwd._maxStackSize` DIRECTLY — `VendorItemsUI::UpdateItemsList` (`0x004c1ea0`, `pc:201085-201133`) displays each browse row's quantity as `min(remaining, _maxStackSize)` (plain `_maxStackSize` for an unlimited listing, via `VendorSubUI::SetObjectStackSize`); `gmVendorUI::InqListSlotCount` (`0x004c0c10`, `pc:200052`) classifies rows on `pwd._maxStackSize <= 1`; the Buy cases (`gmVendorUI::HandleButtonClicks` `0x100000c9` @`pc:203996` / `0x100000cb` @`pc:204086`) gate the stackable-buy path on `pwd._maxStackSize > 1`. `VendorSplitPolicy.ResolveAuthoredStackSize(descStackSize, maxStackSize)` is therefore **max-first** (desc fallback, then 1), consumed only by the vendor-owned paths (`VendorShopItemMaterializer.ToWeenieData`, `VendorUiController.ResolveBuyQuantity`); player-inventory stacks never route through it. Matches the live retail screenshot ("1000 Prismatic Tapers", ceiling 1000 = the taper's authored max stack size). The toolbar-side `gmToolbarUI::HandleSelectionChanged` does read `pwd._stackSize` (`pc:198688`/`198744`/`198774`/`198791`) — on a REAL retail server the two agree for a browse row (the vendor UI stamps the displayed stack from `_maxStackSize`); against ACE (desc always 1) the `_maxStackSize` operand is the one that carries retail's meaning. | `src/AcDream.Runtime/Gameplay/VendorShopItemMaterializer.cs` (`ToWeenieData`); `src/AcDream.Core/Items/VendorSplitPolicy.cs` (`ResolveAuthoredStackSize`); `src/AcDream.App/UI/Layout/VendorUiController.cs` (`ResolveBuyQuantity`) | This is an ACE-server-constraint adaptation on the toolbar leg only: retail's vendor UI reads `_maxStackSize` literally (ported as-is); the toolbar seed's `_stackSize` read is satisfied through the materialized `ClientObject.StackSize`, which this resolution stamps from `_maxStackSize` exactly as retail's own `UpdateItemsList` stamps the displayed stack — not an arbitrary substitute. | A vendor stocking a bounded but non-unit quantity shows a ceiling of `min` semantics only on a real retail server; against ACE the client-side slider ceiling is the authored max stack size, not the bounded stock count — the server remains authoritative and rejects an over-large Buy regardless (a latency/UX gap, not a correctness one — see AP-162). If ACE ever starts serializing a REAL per-listing `_stackSize` (not the constant 1), the max-first preference would hide it; the desc fallback fires only when no authored ceiling exists. | `VendorItemsUI::UpdateItemsList` `0x004c1ea0` `pc:201029-201133`; `gmVendorUI::InqListSlotCount` `0x004c0c10` `pc:200052`; `gmVendorUI::HandleButtonClicks` `pc:203996`/`204086`; `gmToolbarUI::HandleSelectionChanged` `pc:198688-198791`; live vendor-diag wire capture + live retail screenshot (2026-08-08) | | AP-170 | **Filed 2026-08-08, grand-gate finding G3 (out-of-range vendor Use lost silently).** Retail's `ItemHolder::UseObject @ 0x00588A80` has no client-side range check and sends Use immediately regardless of distance — this port's ORIGINAL `RequestUse` faithfully mirrored that shape. Live testing against the user's local ACE server showed it does not hold: walking to a vendor and using it from out of range plays the vendor's cosmetic greeting (a distance-only reaction, independent of Use) but never opens the shop panel — `ApproachVendor` never arrives. ACE's `Player.HandleActionUseItem` (`references/ACE/Source/ACE.Server/WorldObjects/Player_Use.cs:176-215`) explains why: an out-of-range target routes through `CreateMoveToChain(item, (success) => TryUseItem(item, success))` (`Player_Move.cs:37-96`), which polls every 0.1s for the player to reach `WithinUseRadius` and only then calls `ActOnUse` — it does not teleport or server-move the player; it waits for the CLIENT's own walk to land, and a Use that arrives before that poll ever starts observing an in-range player is simply never followed by the vendor's `ApproachVendor` send (`Vendor.ActOnUse`'s own doc comment: "the player will have been commanded to move using `DoMoveTo` before `ActOnUse` is called... it should be assumed that the player is within range" — a precondition our immediate send violated). `SelectionInteractionController.RequestUse` now arms the out-of-range case on the SAME arrival-gated shape `SendPickup`'s close-range (turn-only) branch already used (`RuntimeInteractionTransactionState.TryArmPostArrivalUse`/`TryResolveUseApproachCompletion`, mirroring `TryArmPostArrivalPickup`/`TryResolveApproachCompletion` field-for-field) — the wire Use dispatches only once the local approach naturally completes. An already-in-range Use (a turn at most, or no approach concept applies) is unaffected and still sends immediately, matching ACE's own "already within use distance" synchronous callback. | `src/AcDream.App/Interaction/SelectionInteractionController.cs` (`RequestUse`, `HandleApproachCompletion`, `HandleUseApproachCompletion`, `CancelPendingApproach`, `OnEntityHidden`, `OnEntityRemoved`); `src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs` (`RuntimePendingUse`, `TryArmPostArrivalUse`, `TryResolveUseApproachCompletion`, `TryCancelPendingUse`) | This is an ACE-server-constraint adaptation, not a retail redesign: retail's REAL server walks the player itself before the target's `ActOnUse` ever sees the request, so the client's immediate send never races anything there. ACE does not do this for a player-initiated Use — it only polls and waits — so arming on arrival is required for correctness against the only server this port can test against, not a stylistic preference. | An interaction path that still calls `TryDispatchUse` directly without going through `RequestUse`'s approach gate (none identified at this fix) would keep the original race. The armed reservation is a live busy-count reference until arrival/cancellation resolves it; `ResetCore` releases it unconditionally on any reset/dispose so a teardown that runs without a preceding `CancelPendingApproach()` (e.g. a headless/no-window host with no `SelectionInteractionController`) cannot leak it. | `ItemHolder::UseObject` `0x00588A80`; `Player.HandleActionUseItem` `Player_Use.cs:176-215`; `Player.CreateMoveToChain`/`MoveToChain` `Player_Move.cs:37-153`; `Vendor.ActOnUse` `Vendor.cs:223-266` | -| AP-171 | **Filed 2026-08-08 (user-approved modernization).** Double-clicking a vendor shop item buys it (select + the Buy button's exact quantity/price path). Retail has NO double-click-to-buy — the full named function table was swept at the Slice 6 research and the user chose the addition explicitly after being told. | `src/AcDream.App/UI/Layout/VendorUiController.cs` (shop cell DoubleClicked) | Deliberate QoL divergence, user-directed; trivially removable. | None — additive input affordance; the single-click and Buy-button paths are unchanged. | User direction 2026-08-08 ("When I double click, I should buy it") | +| ~~AP-171~~ | **RETIRED 2026-08-26 — the original filing was false.** Direct named-retail evidence in `gmVendorUI::HandleMousePresses @ 0x004C40D0` calls `BuySingleItem` from the Items-list double-click branch. Browse-row double-click purchase is retail behavior, not an acdream modernization. | `src/AcDream.App/UI/Layout/VendorUiController.cs` (shop cell DoubleClicked) | — | — | `gmVendorUI::HandleMousePresses @ 0x004C40D0`; `docs/research/2026-08-26-retail-inventory-interaction-audit.md` | | AP-173 | **Filed 2026-08-08 (Campaign A slice A2).** Retail pans with `IDirectSoundBuffer::SetPan`, which attenuates ONE output channel by \|pan\| decibels — so full deflection is a 15 dB inter-channel level difference, never full separation. OpenAL exposes no per-channel gain for a mono source, so acdream expresses the same pan as a source-relative AZIMUTH (`MaxPanAzimuthDegrees = 30`, scaled by pan/15) and lets OpenAL's constant-power panner turn it into channel gains. Everything about the pan's SHAPE is retail's and byte-verified: the value is `(int)(-15·sin(Δbearing))` in whole decibels from retail's compass convention, it is forced to dead centre when `(int)distance < 5`, it distinguishes neither front from back nor elevation, and it is frozen for the voice's lifetime. Only the mapping from a 15 dB channel difference to an azimuth under OpenAL's own pan law is approximate. | `src/AcDream.App/Audio/OpenAlAudioEngine.cs` (`MaxPanAzimuthDegrees`, `ApplyPan`) | The exact alternative is to pre-mix a stereo buffer per (wave, pan) pair, which multiplies AL buffer memory by up to the 31 distinct pan values and would fight the 48 MiB LRU; OpenAL's stereo pan law is also driver-dependent, so a measured mapping would not be portable. The audible quantity (inter-channel difference) is preserved in shape and bounded in magnitude. | Stereo image at full deflection may be somewhat wider or narrower than retail's 15 dB; direction and the centre deadzone are correct. Sounds are never hard-panned to silence in one ear the way an uncompressed azimuth would do. | `SoundManager::PlaySoundInternal @ 0x00550170`; `SoundBuf::Play` SetPan call; `docs/research/2026-08-08-audio-retail-soundmanager-core.md` §1 (pan decode) | | AP-174 | **Volume-knob taxonomy differs from retail's, filed 2026-08-08 (Campaign A slice A2).** Retail has exactly three float knobs — `effect_sound_volume`, `ambient_sound_volume`, `interface_sound_volume` — **no master and no music knob**, and the interface one is registered and then never read (interface sounds are scaled by the EFFECT knob). acdream keeps an extra `MasterVolume` on top of `SfxVolume`, which A2 folds into the mixer's single master multiply (`EffectMaster = MasterVolume * SfxVolume`) rather than publishing as an AL listener gain — so the −50 dB no-allocate floor, the audible radius, and the whole-decibel quantisation all move with the slider the way they would if retail had one. `MusicVolume` is dead (retail has no music system at all; slice A6 deletes it) and `AmbientVolume` is unread until slice A5 wires the ambient path. No Interface knob exists yet; slice A4 adds the UI bus and will scale it by the effect knob, matching retail's dead-knob behaviour rather than implementing a working one. | `src/AcDream.App/Audio/OpenAlAudioEngine.cs` (`EffectMaster`); `src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs` | A master slider is a modern nicety users expect and costs nothing once it is inside the one retail multiply; implementing retail's dead interface knob as a working control would be a divergence in the other direction, so it stays dead. | At Master 1.0 (the default) behaviour is bit-identical to a retail single-knob mix. Below 1.0 the mix is quieter than retail's would be at the same effect setting, because retail has no such knob to turn down. | `SoundManager::InitPrefs @ 0x005503F0`; `SoundManager::GetAttenuation @ 0x00550020`; `docs/research/2026-08-08-audio-retail-soundmanager-core.md` §3 D11/D13 | | ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` | diff --git a/docs/plans/2026-08-10-options-panel-campaign.md b/docs/plans/2026-08-10-options-panel-campaign.md index 31591d10..14458996 100644 --- a/docs/plans/2026-08-10-options-panel-campaign.md +++ b/docs/plans/2026-08-10-options-panel-campaign.md @@ -94,8 +94,9 @@ DAT-authored values. register row for the ACE-sourced 2013-unverifiable mapping. - **D4 — Configure Keyboard is the campaign's rebind screen** (it is the ONLY rebind screen — D1). Port `gmKeyboardUI`'s shape and DAT ActionMap - data (lane D Option C) but persist to `keybinds.json`; retail `.keymap` - file interchange is a register-row deferral. + data (lane D Option C). **Superseded 2026-08-26 by #446:** named retail + `.keymap` Load File / Save As/startup/shutdown persistence now ships; + `keybinds.json` remains only the host-command compatibility mirror. - **D5 — dead-endpoint buttons short-circuit to their own retail failure strings.** Urgent Assistance / Report Abuse open a defunct `support.turbine.com` URL in retail; acdream skips the browser launch and @@ -360,8 +361,8 @@ modal capture; right-click erases; N-way cross-map conflicts + the non-user-bindable refusal per lane D §5; Save/Cancel; Reset-to-defaults reloads the DAT maps. Persistence: `keybinds.json` (D4). -**Register rows:** `.keymap` file interchange not implemented (D4); any -retail column/behaviour consciously narrowed. +**Register rows:** any retail column/behaviour consciously narrowed. The +former D4 `.keymap` deferral was retired by #446 on 2026-08-26. **Gate:** connected — rebind a movement key, conflict prompt on a taken chord, persistence across relaunch, reset restores retail defaults. @@ -394,7 +395,8 @@ chord, persistence across relaunch, reset restores retail defaults. `2026-08-09-chat-retail-window-shell.md` §6.3's register row. - A pre-world character-select flow (D6 adapts; its register row carries the future work). -- Retail `.keymap` file read/write (D4 register row). +- None for retail `.keymap` file read/write; #446 implemented it on + 2026-08-26 and retired AP-202. - The `0x21000017` docked `gmPanelUI` host variant — acdream ships the floating host only (register row in OP3 if the review deems it a divergence; retail exposes both). diff --git a/docs/research/2026-08-08-slice6-vendor-transactions-research.md b/docs/research/2026-08-08-slice6-vendor-transactions-research.md index 47052444..af23e0c3 100644 --- a/docs/research/2026-08-08-slice6-vendor-transactions-research.md +++ b/docs/research/2026-08-08-slice6-vendor-transactions-research.md @@ -337,44 +337,18 @@ nothing wrong" surprise, matching the register's existing framing of the ### B.2 — Double-click -**No dedicated double-click-to-buy mechanism was found for vendor shop -items.** Evidence, not absence-of-search: +**Corrected 2026-08-26:** the original symbol-name search missed the real +mechanism. Retail handles this inside the general +`gmVendorUI::HandleMousePresses @ 0x004C40D0`; it does not require a separately +named `CheckForDoubleClick` function. In the Items-list branch, the retail +double-click condition directly calls `gmVendorUI::BuySingleItem` for the +clicked row. The same function also owns staged Buying/Selling removal and +their `ClientLocal` feedback. -- `gmVendorUI::ListenToElementMessage` (`pc:204260-204309`, full function - read) dispatches on message id 1 (button click → - `HandleButtonClicks`), 7 (dropdown selection change), `0x2c` (page - change), `0x15` (drop release), and `0x1c` (routes to - `HandleMousePresses` only when `m_itemsUI != 0`) — there is no distinct - "double-click" message id handled at the panel level. -- The base list class `UIElement_ItemList` (every method enumerated via - `docs/research/named-retail/symbols.json`, ~50 symbols) has - `HandleSingleSelection`, `HandleTargetedUseLeftClick`, - `ItemList_SetSelectedItem`, `ItemList_OpenContainer` (for double-clicking - a CONTAINER item specifically — opening it, not buying), but **no - generic double-click handler** and no vendor-specific one either. -- Other retail panels DO have an explicit, separately-named double-click - handler when the mechanism exists — e.g. `gmContractsUI::CheckForDoubleClick` - (`0x00497A10`), `gmPageListUI::CheckForDoubleClick` (`0x00493140`). No - `gmVendorUI::CheckForDoubleClick` or `VendorItemsUI::CheckForDoubleClick` - symbol exists in the 18,366-function named table. - -**Conclusion:** retail's confirmed vendor-item interaction model is -single-click-to-select (→ drives the global `ACCWeenieObject::selectedID`, -B.3 below) plus an explicit Buy/Add button press. There is no evidence -retail supports double-click-to-buy on the shop list. The user's -expectation likely carries over from inventory-panel muscle memory -(double-click = use/equip elsewhere in retail) — but the vendor "Items" -list is not that panel. **This is flagged as an open question for the -contract, not resolved unilaterally**: per the project's -no-invented-mechanisms discipline, do not silently add a double-click-buy -shortcut and call it retail-faithful. The retail-faithful, fully-evidenced -fix for "double-click does nothing" is: (a) make single-click meaningfully -select (today it only sets a private field with no visible effect — see -B.3), and (b) make the Buy button actually work. If the user still wants a -double-click shortcut after seeing single-click+Buy work, that is a -deliberate, flagged acdream UX addition on top of retail, not a retail port -— call it out explicitly in the commit/register the way AP-116 -(Particle Range) or similar user-directed deviations are recorded. +**Conclusion:** browse-row double-click-to-buy is verbatim retail behavior. +The acdream binding is a port, not an optional modernization. The previous +absence-of-symbol inference was false and is superseded by the direct function +body. ### B.3 — The quantity slider @@ -735,10 +709,9 @@ concretely unblocked by the one before it; skipping ahead reproduces the polish, not correctness — the server is authoritative either way) and file it as a fast follow-up if the user notices the round-trip lag on a refused purchase. -2. **Double-click** — no retail mechanism found (B.2). Ask the user - directly whether they want a deliberate acdream-only double-click - shortcut once single-click-select + Buy-button-works is verified live, - rather than assuming yes and inventing behavior. +2. **Double-click — RESOLVED 2026-08-26.** Retail's + `gmVendorUI::HandleMousePresses @ 0x004C40D0` directly buys a browse row on + double-click. Keep this behavior and its staged-row siblings. 3. **Where does the vendor-owned split-exempt-mask predicate live** — C.1's design question: fold into `SelectedObjectController` directly (it already owns the seeding logic, would need a `Func diff --git a/docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md b/docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md index 9240d7b4..aaa846da 100644 --- a/docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md +++ b/docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md @@ -5,6 +5,13 @@ Research lane D of the settings-track campaign questions **Q5** (Configure Keyboard: retail's keymap UI + storage) and **Q6** (what every Gameplay Options tab button does). +> **2026-08-26 implementation addendum:** the report below describes the +> pre-OP8 state and its design choices at that date. acdream has now shipped +> Option C end to end: all 306 installed-DAT rows, retail conflicts/capture, +> and real named `.keymap` Load File / Save As/startup/shutdown persistence. +> See `docs/research/2026-08-26-retail-keyboard-routing-audit.md`; AP-202 is +> retired. + **Report only.** No repo code was changed. Every retail claim below carries a named symbol + address from the Sept 2013 EoR PDB-paired build. The PDB/binary pairing was verified first: diff --git a/docs/research/2026-08-26-combined-client-parity-gate.md b/docs/research/2026-08-26-combined-client-parity-gate.md new file mode 100644 index 00000000..ed1db04e --- /dev/null +++ b/docs/research/2026-08-26-combined-client-parity-gate.md @@ -0,0 +1,131 @@ +# Combined client parity owner gate + +**Date prepared:** 2026-08-26 + +**Run:** OWNER-COMPLETE 2026-08-26 on one exact Release binary. + +**Scope:** #443–#450 plus the complete inventory/vendor interaction audit. + +**Outcome:** Sections A–C and E–F passed, including the mid-drag cursor-icon +re-test added during the round. Issues #444–#450 are owner-accepted. Section D +failed: the private paperdoll remained missing, so #443 stays open as the only +surviving defect. Tested executable SHA-256: +`173989F3C85C05C0746D628CDF9C6194F6A5E3EFD4597806BF83417483C37B42`. + +Keep the client log for the whole run and take a screenshot for any visual or +text mismatch. For every refused item action, record the cursor color, exact +SpewBox line, and whether the item visibly moved before the refusal. + +## A. Keyboard, camera, combat input, and relog — #446/#450 + +1. In Configure Keyboard, bind Move Forward to bare Shift while Toggle + Walk/Run already owns it. Verify the retail conflict dialog appears and the + chosen resolution is honored. Repeat with a known allowed shared chord. +2. Apply a changed binding, close/reopen Options, then restart the client. + Verify it survives. Test Revert, Defaults, Cancel, and Load/Save `.keymap`. +3. Press regular Enter: chat input must focus. Press keypad Enter outside chat: + it must perform only its configured camera action and must not focus chat. +4. Press bare Escape through the retail ladder: cancel target/focus/selection + first, then toggle Gameplay Options. It must never enter a developer orbit + or bird's-eye mode. Preserve the approved mouse-wheel zoom range. +5. Hold End, Page Down, or Delete in melee mode. The bar must charge while the + key is held and attack only on release, using the selected height. +6. Shift+Escape to character selection, immediately re-enter, and verify the + destination loads and exits portal space. Repeat twice in one process. + +## B. Chat and combat text — #447/#448 + +1. Run `@acecommands`. Every non-empty server line must be visible; an + oversized response must retain the newest complete lines, not blank the + transcript. +2. Run `@acehelp acecommands` and send ordinary chat afterward. Verify normal + text, filtering, and scrolling remain intact. +3. Land ordinary and critical melee hits. Outgoing lines must match retail + wording and punctuation and contain no acdream-added percentage. + +## C. Selection, use, containers, movement, and splitting — #445/#449 + +Use a normal item, unusable item, wearable, weapon, two mergeable stacks, a +Pyreal stack, one side pack, a full main pack, a full side pack, an open +external container, and a creature/player target. + +1. Single-click and right-click inventory, side-pack, external-container, and + paperdoll items. Verify one global selection, stable highlight, status text, + and right-click examination. +2. Select an owned Pyreal stack. The toolbar must read + ` (of )`, with no comma + insertion added by acdream. +3. Single- and double-click a carried side pack. It must open on press, issue + no generic item-use request, and remain stably selected. +4. Double-click usable, unusable, wearable, and wieldable items. Verify one + action. Any local refusal must appear once in SpewBox, not in normal chat. +5. Move a full item between main pack and side pack. Before acknowledgement, + the source must remain canonical with retail's waiting/ghost presentation; + after success it appears only at the destination. Force one rejection and + verify no duplicate, disappearance, or speculative capacity change. + While holding the item under the cursor, move across the inventory and wait + through several ordinary object updates: the cursor icon must remain visible + until release. This re-gates the mid-drag procedural-refresh fix found during + the first 2026-08-26 owner pass. +6. Fill the main pack, observe a rejected move, drop one loose item, then move + an item from a side pack into the freed slot immediately. It must accept + without reopening the inventory window (#449). +7. From a stack of 10, select 2 and split into an empty main-pack slot, an open + side pack, and an external container. Each successful result must be 8+2. +8. Merge full and partial stacks. Verify selected quantity, target selection, + target-cap clamp, source remainder, and exact refusal text for a full target. +9. Drop full and partial stacks to the world, then pick them up. Verify pending + visuals, authoritative commit, failure cleanup, and no duplicate object. +10. Give a full and partial stack to a creature; drag onto another player and + verify secure-trade routing. Hover rejection must stay silent; release + rejection must print the exact ClientLocal reason. +11. Equip once by double-click and once by paperdoll drag. Test a clothing + conflict and weapon replacement. Canonical inventory/paperdoll ownership + must not change before the authoritative response. + +## D. Private paperdoll viewport — #443 + +1. From a fresh process, open inventory and assess one monster and one player. +2. The animated doll must be present on first open, not appear only after a + delay. Close/reopen each view several times and change equipment once. +3. Record #443 independently if the viewport is late or missing even when the + underlying equip/inventory transaction is correct. + +## E. Vendor parity and alternate currency — #444/#445 + +1. On the browse list, single-click selects, right-click examines, and + double-click buys exactly one/current-slider unit through the normal retail + purchase path. +2. Add a stack quantity greater than one to Buying. Double-click its staged + row: remove exactly one unit and print + `Removing from shopping list` once in SpewBox. +3. Stage an owned item in Selling. Right-click examines it; double-click + removes the whole staged entry and prints the same removal form. +4. Drag a staged Selling row: it must unstage. Repeat after selecting only part + of its stack: SpewBox must print + `You cannot split items from this panel` and the slider must reset to max. +5. From an owned stack of 10, select 2 and drag into Selling. Verify the + temporary row resolves to the new authoritative stack of 2, Sell All sells + exactly 2, and 8 remain (#445). +6. At an alternate-currency vendor, note holdings in the Items cost sentence + and Buying/Selling purse lines. Buy once: every visible holding must decrease + immediately and remain correct after authoritative inventory refresh, + tab changes, and vendor reopen (#444). +7. Buy All once with enough currency and once without enough. The first uses + the refreshed balance; the second prints retail's insufficient-funds notice + and sends no purchase. + +## F. Re-entrant and lifecycle stress + +1. Change selection during a pending split, close an external container during + a pending move, and retry immediately after a refusal. +2. Attempt a second inventory operation while one request is pending. Verify a + clean retail refusal/no-op, never duplicated wire action or stuck busy state. +3. Log out or portal with a recently completed interaction, re-enter, and + verify pending projections and the request ledger converge to zero. + +## Pass rule + +The pass rule was satisfied for #444–#450 on the exact binary recorded above. +#443 remains open by itself because the transaction rows passed and the failure +was confined to private viewport residency. diff --git a/docs/research/2026-08-26-issues-444-445-447-test-script.md b/docs/research/2026-08-26-issues-444-445-447-test-script.md new file mode 100644 index 00000000..7ec5bac3 --- /dev/null +++ b/docs/research/2026-08-26-issues-444-445-447-test-script.md @@ -0,0 +1,43 @@ +# Issues #444, #445, #447 — consolidated owner gate + +Run these checks together on the next connected test build. They deliberately +require no special probes; record the client log and one screenshot per +section. If a split fails, also record the exact visible error text. + +## #444 — alternate-currency vendor balance + +1. Open a vendor that accepts an alternate currency and note the amount shown + in both the Items cost sentence and the Buying-tab purse line. +2. Buy one item. +3. Confirm both visible amounts decrease immediately and remain correct after + the server refresh. Close/reopen the tab and vendor and confirm the amount + does not bounce back to the old snapshot. +4. Buy All once with enough currency, then once without enough. Confirm the + first uses the refreshed holding and the second shows retail's insufficient- + money notice without sending a purchase. + +## #445 — inventory and vendor partial stack splits + +1. In the main pack, select a stack of 10, set the slider to 2, and drag it to + an empty main-pack slot while at least one side bag is equipped. +2. Confirm the source becomes 8 and a new stack of exactly 2 appears at the + chosen loose-item position; no error should appear. +3. Repeat into an open side bag and confirm the same 8+2 result. +4. Reset to a stack of 10, select 2, and drag it onto the vendor Selling list. + Confirm the client prints `Splitting the before selling them`, then + the staged row resolves to the new stack of 2 rather than the source stack. +5. Press Sell All and confirm exactly 2 are sold and 8 remain. + +## #447 — `@acecommands` multiline response + +1. Run `@acecommands` on the test account. +2. Confirm command text is visible, consecutive server lines are readable, + and there is no screen of blank chat rows. +3. Scroll through the retained result. A response beyond retail's 10,000- + character transcript cap should retain the newest complete command lines + instead of blanking the entire response. +4. Run `@acehelp acecommands` and one ordinary chat command afterward; confirm + their text and normal chat presentation remain intact. + +Pass all three sections on one exact binary, then mark #444/#445/#447 +owner-accepted together. diff --git a/docs/research/2026-08-26-retail-inventory-interaction-audit.md b/docs/research/2026-08-26-retail-inventory-interaction-audit.md new file mode 100644 index 00000000..028c7a3b --- /dev/null +++ b/docs/research/2026-08-26-retail-inventory-interaction-audit.md @@ -0,0 +1,745 @@ +# Retail inventory interaction audit + +**Date:** 2026-08-26 + +**Scope:** Selection, status text, single/double/right click, drag/drop, +container movement, ground pickup/drop, equipping, stack splitting, vendor +staging, failure feedback, and SpewBox routing. + +**Change policy:** Audit followed by implementation in the same worktree. + +**Source snapshot:** `0c699240`, plus the already-present working-tree fixes for +#444, #445, #446, #447, and #449. Those fixes are assessed as found; this +report does not claim that they have been committed or user-accepted. + +## Implementation closeout — 2026-08-26 + +Slices 1–4 below are implemented and automated-test covered. Gameplay +refusals now use the `ClientLocal` SpewBox route; move/wield failure kinds are +complete; full move/drop/wield are request-first with pending projections; +owned-container and vendor-row mouse behavior follows the named retail +handlers; hover and release share one side-effect-free legality policy; and +local item-policy wording is composed from retail's exact literals. + +The last toolbar uncertainty is also resolved. Raw retail bytes at +`gmToolbarUI::HandleSelectionChanged @ 0x004BF4EF` push format literal +`0x007B4748`, which decodes to `%d %hs (of %d)`. The owned Pyreal-stack branch +now renders that exact stack/name/total shape. The final AutoWield fallback was +also corrected: retail does not print the invented “That slot is already in +use”; with automatic unblocking enabled it moves the preferred occupied-slot +item to the backpack, waits for the authoritative move, then retries the +wield. Slice 5 remains deliberately deferred as the single combined connected +owner gate. + +## Audited root causes (now fixed) + +The inventory implementation was not missing one isolated rule. Most individual +operations existed and used the correct wire messages, but three seams made the +whole experience feel intermittent: + +1. **Some retail-local refusal text was routed to a dead production callback.** + `ItemInteractionController` and `AutoWieldController` used a `toast` callback + for a substantial class of local rejections while `GameWindow` supplied + `null`. Retail sends these messages to the `ClientLocal` channel, which is + the SpewBox in acdream. The result was a real silent-failure class, not merely + different wording. +2. **Full moves, world drops, and wield operations mutated canonical inventory + state before the server accepted them.** Retail normally leaves the source + canonical object in place, adds a waiting/ghost projection at the intended + destination, and commits only after the authoritative object update. The + old optimistic mutation was reversible, but selection, capacity, + paperdoll, vendor, and other observers could see a transient state that never + existed on the server. This was the largest structural flakiness risk. +3. **Several list-specific mouse behaviors did not match retail.** In + particular, staged vendor rows could not be double-clicked or dragged to + remove them, staged rows lacked right-click examine, and owned side-pack + double-click/open ordering differed from retail. + +The wire builders, global selection/split model, merge-first rule, request gate, +most right-click examine paths, normal item double-click use/equip, external +container pickup, paperdoll placement validation, and the newly repaired +vendor-split/main-pack-capacity paths are broadly aligned with retail. + +The implementation was executed in this order: + +1. Route every local item refusal through `ClientLocal`/SpewBox. +2. Replace canonical optimistic movement with retail-style pending projections. +3. Close the vendor staged-row and owned-container input differences. +4. Deepen hover/drop legality and finish exact status/failure text parity. +5. Run one connected interaction matrix across inventory, paperdoll, ground, + external containers, and vendors. + +## Method and evidence standard + +This audit used four evidence layers: + +- The September 2013 named retail pseudo-C under + `docs/research/named-retail/acclient_2013_pseudo_c.txt`, searched by named + class and method before relying on older address-only material. +- Existing focused retail notes under `docs/research/`, especially the item, + drag, give, world-drop, use/autowear, and vendor investigations. +- The current production controllers, Runtime owners, UI input dispatch, wire + request builders, and communication routing. +- Existing focused tests, used to distinguish implemented intent from behavior + that is not currently protected. + +Verdicts in this report mean: + +- **Match:** the important retail behavior and ownership rule are present. +- **Partial:** the common path matches, but a retail branch, presentation rule, + or failure path is absent. +- **Mismatch:** direct retail evidence contradicts the current behavior. +- **Risk:** the mechanism differs in a way likely to produce transient or race + defects, but this audit does not assert a particular live symptom without a + connected reproduction. +- **Gate pending:** a code fix exists in the working tree and has automated + coverage, but the owner has not yet accepted the live behavior. + +## Retail reference model + +### One selected object and one split quantity + +Retail has a client-global selected object. Clicking an item selects it; +right-click first selects it and then examines it; beginning a drag selects it +if it was not already selected. The toolbar observes that global selection and +shows the name, stack quantity, and split controls. + +The split quantity is also global and applies only when the dragged/requested +object is the selected object. An unselected stack always means the full stack. +Changing selection resets/reseeds the split amount. Vendor-owned selected +stacks use a different initial amount from normal owned stacks. + +Primary anchors: + +- `UIElement_ItemList::ListenToElementMessage` at `0x004E4D50` +- `UIElement_ItemList::BeginDrag` at `0x004E32D0` +- `gmToolbarUI::HandleSelectionChanged` at `0x004BF380` +- `ItemHolder::GetObjectSplitSize` in the named retail pseudo-C + +### Mouse-down establishes intent; click completion performs list action + +For a retail item-list entry, left press first gives target mode a chance to +consume the object. Otherwise it selects the object. A container-list entry +also opens that child container and updates its open indicator in this same +item-list message path. + +Right press selects and examines. Double-click invokes generic `UseObject` for +ordinary list items, but the generic double-use path is suppressed for an +owned `containerList` entry. The ground/external root is explicitly allowed. + +This distinction matters: a side pack is opened as a container, not opened and +then generically used as an ordinary item on the second click. + +### Dragging is a request with pending presentation + +Beginning a physical-item drag produces a source waiting/ghost state. Vendor, +salvage, and shortcut lists are special list types and do not use the same +physical-source waiting ghost. + +Hover is advisory and silent. Release reruns legality with feedback enabled. +For a normal container move, retail retains the canonical source ownership and +adds a pending destination projection. The server's authoritative object update +commits the move. Rejection removes the pending projection and prints the local +failure. This same general principle appears in world placement and split-to- +world handling. + +Primary anchors: + +- `UIElement_ItemList::BeginDrag` at `0x004E32D0` +- `UIElement_ItemList::DragOver` at `0x004E3400` +- `UIElement_ItemList::AcceptDragObject` at `0x004E4250` +- `UIElement_ItemList::HandleDropRelease` at `0x004E4790` +- `ItemHolder::AttemptToPlaceInContainer_IsItemLegal` at `0x005870C0` +- `ItemHolder::AttemptToPlaceInContainer_IsContainerLegal` at `0x005879B0` +- `ItemHolder::WillItemFitInContainer` at `0x00587D60` +- `ItemHolder::IsDragIntoContainerAttemptLegal` at `0x00587E90` + +### Drop target dispatch is ordered + +Retail's three-dimensional drop/give dispatcher follows this practical order: + +1. Require an owned, movable source that is not currently in trade. +2. Dropping on self means the main backpack. +3. Target zero means ground placement or split-to-world. +4. Try stack merge before treating the target as a container. +5. A player target opens/routes through secure trade. +6. A creature target uses give-item behavior. +7. A container target must be open, unlocked, and legal. +8. Vendor lists use their own staging rules. +9. Otherwise resolve as a ground placement or refuse it. + +`AttemptMerge` uses the selected split amount, clamps to target capacity, sends +the merge request, and selects the target stack. Give-item is request-only; it +does not optimistically remove the source from canonical inventory. + +Primary anchors: + +- `ItemHolder::AttemptMerge` at `0x005878F0` +- `ItemHolder::AttemptPlaceIn3D` at `0x00588600` +- `docs/research/2026-07-13-retail-give-item-pseudocode.md` +- `docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md` + +### Use and equip + +Generic double-click use passes through `ItemHolder::DetermineUseResult` and +`ItemHolder::UseObject`, with a short use throttle. The item is classified as +direct-use, targeted-use, pickup, equip/autowear, trade, salvage, or game use. +Retail locally refuses invalid states and prints a `ClientLocal` message. + +Paperdoll 3D clicks and discrete equipment-slot lists share the same global +selection/examine model. Dropping on a paperdoll location validates the exact +location, then chooses auto-wear or auto-wield behavior. Clothing overlap can +be rejected locally; weapon replacement has different rules. + +Primary anchors: + +- `ItemHolder::DetermineUseResult` at `0x00588460` +- `ItemHolder::UseObject` at `0x00588A80` +- `CPlayerSystem::UsingItem` at `0x00562F70` +- `gmPaperDollUI::ListenToElementMessage` at `0x004A5C30` +- `gmPaperDollUI::AcceptDragObject` at `0x004A3B10` +- `gmPaperDollUI::AcceptPaperDollDragObject` at `0x004A4A70` +- `docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md` + +### Vendor rows are active item lists + +Direct named-retail evidence establishes these behaviors: + +- Double-clicking a vendor browse row buys one item. +- Double-clicking a staged buying row removes it and prints + “Removing %s from shopping list” through `ClientLocal`. +- Double-clicking a staged selling row removes it, clears its sell state, and + prints the same form of message. +- Dragging an already-staged selling row removes it from the staged list. +- If a partial split is selected while dragging a staged selling row, retail + refuses to split that row, prints “You cannot split items from this panel”, + and resets the split control to the stack maximum. +- A new partial-stack drag into the Selling list sends a split request, creates + a temporary staged row, and replaces that row when the new matching object + arrives. +- Hover rejection is silent; release rejection prints to `ClientLocal`. + +Primary anchors: + +- `gmVendorUI::HandleMousePresses` at `0x004C40D0` +- `gmVendorUI::RecvNotice_ItemListBeginDrag` at `0x004C4380` +- `VendorSellUI::DragItemAcceptable` at `0x004C20C0` +- `VendorSellUI::AcceptDragObject` at `0x004C4F00` +- `VendorSellUI::ItemAttributesChanged` at `0x004C3FD0` + +This corrects an older project research conclusion: browse-row double-click +buy is retail behavior. It is not an acdream modernization. + +### Feedback destination + +Retail item-policy and request-failure messages are sent on the local client +text channel. In acdream, `RuntimeCommunicationState.AddText` maps +`ClientLocal` (`0x1A`) to the SpewBox only: it does not add the line to the +chat transcript and does not apply a chat timestamp. + +Hover failures are normally silent. Release/action failures are not. Server +request failures are composed by `ACCWeenieObject::ServerSaysAttemptFailed` +at `0x0058EAE0`, including move and wield failures. + +## Current acdream ownership and routing + +The relevant production flow is: + +```text +UiRoot / UiItemSlot + -> InventoryController | ExternalContainerController | PaperdollController + | VendorUiController | SelectedObjectController + -> ItemInteractionController / AutoWieldController + -> RuntimeInventoryState + RuntimeActionState transactions + -> ClientObjectTable (canonical object ownership) + -> outbound request builder + -> authoritative object update / request failure + -> RuntimeCommunicationState.ClientLocal -> SpewBox +``` + +Important owners: + +- `SelectionState` is the sole selected-object owner shared by inventory, + paperdoll, vendor, world selection, and toolbar status. +- `RuntimeInventoryState` owns external-container state, item-use transaction + state, shared busy/request state, split/pending placement state, and borrows + the canonical `ClientObjectTable`. +- `SelectedObjectController` projects selection into the authored toolbar and + owns the split-slider presentation. +- `ItemInteractionController` classifies use/drop/give/move operations and + sends requests. +- `InventoryController`, `ExternalContainerController`, `PaperdollController`, + and `VendorUiController` own their list-specific input and projections. + +This ownership shape aligns with the architecture document. The central issue +is not duplicate state; it is which state is mutated before acknowledgement. + +## Behavior matrix + +| Surface/action | Retail | Current acdream | Verdict | +|---|---|---|---| +| Inventory left press | Target-mode consume, otherwise select | `PrimaryItemPressed` does the same | Match | +| Ordinary item single click | Select; no generic use | Mouse-down selects | Match | +| Ordinary item double-click | Generic use/equip | `DoubleClicked = ActivateItem` | Match | +| Owned side-pack single press | Select and open in the item-list handler | Selects and opens on mouse-down | Match, implemented | +| Owned side-pack double-click | Open behavior; generic item double-use suppressed | Opens once; generic activation is suppressed | Match, implemented | +| Inventory right-click | Select, then examine | Select and examine | Match | +| Drag lift | Select if needed; source ghost | Selects and ghosts | Match | +| Drag hover | Silent, legality-aware green/red | Silent and shares the release legality decision | Match, implemented | +| Full internal move | Request plus pending destination projection; canonical source waits for server | Request-first pending projection; authoritative update commits | Match, implemented | +| Merge stacks | Merge before container placement; selected split amount; select target | Same broad behavior | Match | +| Partial move to container | Split request; wait for authoritative object | Request-only | Match | +| Drop to ground | Request/pending presentation; source remains canonical until response | Request-first; canonical source waits for response | Match, implemented | +| Split to ground | Global pending split; select arriving matching object; timeout | Request/pending path exists | Broad match | +| Pick up from ground | Pending destination projection; authoritative commit | Pending destination path | Match | +| Open external container | Root/nested list-specific behavior | Root double-click, nested open behavior | Broad match | +| Move to external container | Request-only, open/unlocked legality, server commit | Request-only with shared hover/release legality | Match, implemented | +| Give to creature | Request-only; selected split amount | Request-only | Match | +| Give/drop to player | Secure-trade routing | Secure-trade routing exists | Broad match | +| Paperdoll click/right-click | Global select/examine | Global select/examine | Match | +| Paperdoll drag equip | Exact location validation; auto-wear/wield | Same broad split | Broad match | +| Full wield | Authoritative request model | Request-first; canonical ownership waits for response | Match, implemented | +| Invalid item use/equip | ClientLocal text in SpewBox | Shared `ReportClientLocal` route | Match, implemented | +| Selected status | Normal name or `{quantity} name`; owned coin is `%d %hs (of %d)` | Both branches implemented | Match, implemented | +| Split applicability | Only selected stack uses global quantity | Same | Match | +| Vendor browse single/right | Select; right-click examine | Select and right-click examine | Match | +| Vendor browse double | Buy one | Buy one | Match | +| Drag inventory to Selling | Stage full or selected partial quantity | Present; partial temp-row replacement present | Match, #445 gate pending | +| Vendor hover refusal | Silent | Silent | Match | +| Vendor release refusal | ClientLocal/SpewBox | System message/SpewBox path | Match | +| Staged Buying double-click | Remove one + SpewBox line | Same | Match, implemented | +| Staged Selling double-click | Remove row, clear state + SpewBox line | Same | Match, implemented | +| Staged Selling drag | Remove row; partial selection warns and resets split | Same, exact refusal + reset | Match, implemented | +| Staged row right-click | Generic select/examine item-list behavior | Select and examine on every vendor list role | Match, implemented | +| Main-pack capacity | Items and carried containers counted separately | Separate loose-item count now present | Match, #449 gate pending | +| Server move/wield failure text | Exact ClientLocal move/wield compositions | Both request kinds and compositions present | Match, implemented | + +## Findings + +### F1 — local inventory refusals can be completely silent + +**Resolution:** CLOSED IN CODE — one `ReportClientLocal` route now selects +interface text, system text, or the test fallback in that order. + +**Priority:** P0 + +**Confidence:** Confirmed by production composition + +`ItemInteractionController` uses two different presentation routes: + +- `_systemMessage` / `_interfaceText`, which are wired to + `RuntimeCommunicationState.AddText(..., ClientLocal)` and reach SpewBox. +- `_toast`, used by many local policy refusals. + +`InteractionRetainedUiComposition` forwards its `toast` dependency, but +`GameWindow` currently sets the production composition toast to `null` after +the developer-toast surface was removed. Consequently, the local rejection +still aborts the action, but the user receives no explanation. + +Affected classes include invalid item use, missing use target, trade/wield +requirements, locked or unsuitable targets, invalid move/give/drop states, +midair/drop refusal, and paperdoll slot-in-use refusal. Exact membership should +be frozen in a focused message-routing test before changing it. + +Retail evidence is unambiguous: these are local client text messages and belong +in SpewBox, not a transient developer toast. + +**Future fix:** remove the semantic split for gameplay failure text. Give item +controllers one `ClientLocal` sink and reserve any visual toast mechanism for +non-retail developer/launcher notifications. + +### F2 — optimistic canonical moves expose impossible intermediate state + +**Resolution:** CLOSED IN CODE — full move, world drop, and wield dispatch +requests without mutating canonical ownership; pending source/destination +presentation converges on confirmation, failure, and reset. + +**Priority:** P0 architectural correction + +**Confidence:** Confirmed mechanism divergence; symptom linkage requires gates + +The full-stack internal move and world-drop paths use optimistic operations +against the canonical object table. Full wield uses the same pattern. Failure +rollback exists, but all borrowers can observe the speculative state: + +- selection and toolbar status; +- loose-item and carried-container capacity; +- paperdoll slots; +- vendor sell eligibility/staging; +- external-container views; +- plugins and Runtime views. + +Retail instead keeps source canonical ownership stable and uses waiting/ghost +presentation at the intended destination until the server update arrives. + +This does not prove that every reported intermittent inventory symptom comes +from this seam. It does explain why otherwise-correct controllers can disagree +briefly and why a rejection/late response/re-entrant action can make the UI feel +flaky. + +**Future fix:** model full move/drop/wield like the existing request-only split, +give, ground-pickup, and external-container paths. Store a generation-scoped +pending placement intent and presentation ghost, send the request, and let the +authoritative update commit canonical ownership. On failure/timeout/reset, +remove only the pending presentation. + +### F3 — vendor staged-row removal behavior is missing + +**Resolution:** CLOSED IN CODE — staged rows implement the retail +double-click, right-click, drag-lift, message, and split-reset branches. + +**Priority:** P1 + +**Confidence:** Confirmed by direct named-retail functions + +Current staged Buying and Selling rows only bind selection. They have no +double-click removal. Selling rows also disable drag source behavior. + +Retail supports: + +- double-click staged Buying to remove; +- double-click staged Selling to remove and clear sell state; +- drag staged Selling to remove; +- a precise ClientLocal removal line; +- a partial-split refusal/reset when dragging from the staged Selling list. + +**Future fix:** add list-role-specific actions rather than routing these rows +through generic item activation. Protect each action with unit tests that also +assert selection, sell-state cleanup, totals, and exact SpewBox routing. + +### F4 — owned side-pack click/double-click sequencing differs + +**Resolution:** CLOSED IN CODE — carried containers open on press and the +generic double-use route is suppressed for that list role. + +**Priority:** P1 + +**Confidence:** Confirmed structural mismatch + +Retail opens a carried child container in the item-list press handler and +suppresses generic double-click use for a `containerList` item. acdream selects +on mouse-down, opens on completed click, and binds generic activation to double +click for every inventory cell. `UiRoot` emits the second click before the +double-click event, so a double-click can both open and activate the pack. + +This is a plausible source of redundant requests and awkward drag/open +interactions. It should be fixed by explicit item-list role, not by a global +double-click timing change, because ordinary items and the external-container +root intentionally retain double-click use/open behavior. + +### F5 — hover acceptance is less strict than release/server legality + +**Resolution:** CLOSED IN CODE — `InventoryContainerPlacementPolicy` is the +shared silent-hover/speaking-release decision for owned and external lists. + +**Priority:** P1/P2 + +**Confidence:** Confirmed code difference + +Inventory-grid hover mostly checks list role, basic object class, and capacity. +External-container hover is broader still. Retail's predicates incorporate +ownership, trade state, source/destination identity, real carrying-container +restrictions, open/locked state, destination capacity type, and other legal +conditions. + +The practical symptom is a green cursor followed by a refusal or apparent +no-op on release. Hover must remain silent, but its boolean should be produced +from the same pure legality decision used at release. + +**Future fix:** extract one side-effect-free placement decision that returns a +reason code. Hover consumes only allowed/denied; release converts the same +reason to exact ClientLocal text. + +### F6 — selected status lacks retail's owned-coin special case + +**Resolution:** CLOSED IN CODE — the PDB-matched retail executable resolves +the literal at `0x007B4748` to `%d %hs (of %d)`; the controller now reads the +player's `CoinValue` and uses that exact branch for owned WCID 273 stacks. + +**Priority:** P2 + +**Confidence:** Byte-resolved from the PDB-matched retail executable + +Normal current text—name for a singleton and `{stackSize} {name}` for a +stack—matches the main retail branch. Retail has an additional owned-coinstack +formatting branch that derives a total/value-aware display and name. The +current controller always uses the generic stack prefix. + +Binary inspection resolves the apparent vtable-symbol artifact: the raw call +site pushes `0x007B4748`, `%d %hs (of %d)`, with stack size, appropriate name, +and the player's integer `CoinValue` as its three arguments. + +### F7 — request failure coverage omits move and wield kinds + +**Resolution:** CLOSED IN CODE — both request kinds are represented and route +through the item-aware retail failure composer. + +**Priority:** P2 + +**Confidence:** Confirmed enum/composer gap + +Retail's `ServerSaysAttemptFailed` includes move and wield result families. +The current request-failure model and `InventoryFailureMessages` cover merge, +split, pickup, put, drop, and give, but do not represent the retail move/wield +families. A server-side failure in those operations therefore cannot produce +the exact item-aware retail sentence through the common composer. + +### F8 — current retail-divergence documentation is wrong about vendor double-click + +**Resolution:** CLOSED — the older research is corrected and AP-171 retired. + +**Priority:** Documentation correction before implementation + +**Confidence:** Confirmed by direct named-retail evidence + +Older vendor research and AP-171 characterize double-click browse-row purchase +as an acdream enhancement. `gmVendorUI::HandleMousePresses` directly calls +`BuySingleItem` on the retail Items-list double-click. Current browse behavior +is correct; the documentation is not. Leaving this claim in the register risks +a future parity cleanup deleting a retail feature. + +### F9 — #445 and #449 need connected acceptance, not more inference + +**Priority:** Gate now + +**Confidence:** Automated fixes present + +- #445 now uses the selected split quantity for vendor selling, creates a + temporary staged row, and replaces it when the authoritative split object + arrives. +- #449 now counts loose items separately from carried container objects when + deciding whether the main backpack is full. + +Both have focused tests in the current working tree. Neither should be marked +closed until a live server gate covers success, refusal, repeated action, and +selection changes. + +### F10 — paperdoll disappearance is a separate rendering/residency defect + +**Priority:** Keep separate from transaction fixes + +**Confidence:** Existing issue #443 + +The intermittent missing paperdoll that heals after a delay is tracked as +paperdoll first-open/residency behavior. It can make a correct equip transaction +look broken, so it belongs in the combined user gate, but it should not be +folded into inventory ownership or input logic without evidence. + +## SpewBox contract + +The following should appear in the SpewBox through `ClientLocal` when the user +commits the action and it is refused or changed: + +- invalid use/equip/wield state; +- “choose a target” or invalid target; +- cannot move/drop/give an item; +- locked, closed, full, or otherwise illegal destination; +- merge/split/pickup/put/drop/give/move/wield request failure; +- vendor item cannot be sold or split in that list; +- removal from a vendor shopping/selling list; +- automatic removal of conflicting wear items where retail reports it; +- midair or other locally cancelled placement when retail reports it. + +The following should be silent: + +- merely hovering a rejected drop target; +- moving the pointer away without releasing; +- ordinary selection changes; +- beginning a legal drag. + +These messages should not be duplicated into the normal chat log and should +not gain chat timestamps. That is already how `ClientLocal` behaves in the +communication owner. + +## Existing automated coverage + +The repository already has strong narrow coverage in: + +- `InventoryControllerTests`: population, selection, open/right-click, + drag/ghost, pending pickup, split, merge, capacity, rollback, and #449. +- `ExternalContainerControllerTests`: root/nested behavior, selection, + right-click, partial split, and pending gates. +- `PaperdollControllerTests`: selection, examine, drag, and wield placement. +- `SelectedObjectControllerTests`: name, stack status, slider, and vendor split + initialization. +- `VendorUiControllerTests`: browse, buy quantities, selection/examine, + staging, partial vendor split/failure, rejection feedback, and alternate + currency. +- `ItemInteractionControllerTests`: use/equip, world drop, give, partial-stack + behavior, failures, and transaction lifecycle. +- Runtime inventory tests: request ownership, reset, and lifecycle behavior. + +The pre-implementation test suite was strongest at proving controller-local +intent. The implementation program below adds the missing transaction and +cross-controller coverage. + +## Automated gates added by the implementation + +The implementation adds or updates coverage for the following: + +1. A production-composition test proving every local policy rejection reaches + `ClientLocal`/SpewBox and no gameplay failure depends on a toast callback. +2. Owned side-pack single/double-click tests proving one open action and no + generic use request, including the second-click event order. +3. Vendor staged Buying and Selling double-click removal tests with exact + selection, totals, state cleanup, and message assertions. +4. Vendor staged Selling drag-to-remove and selected-partial split-reset tests. +5. Staged vendor-row right-click select/examine tests. +6. A table-driven pure legality test shared by hover and release for inventory, + external container, ground, player, creature, vendor, self, locked container, + full item slots, and full container slots. +7. Owned coinstack toolbar-status parity using the byte-resolved exact format. +8. Move and wield authoritative failure-composition tests. +9. Transaction-observer tests proving canonical ownership does not change + before acknowledgement while selection, capacity, vendor, and paperdoll + borrow the same state. +10. Re-entrant sequences: drag while a request is pending, selection change + during split, rejection after container close, late response after session + reset, and repeated action after rollback. + +## Executed implementation program + +### Slice 1 — feedback integrity — COMPLETE + +- Replace gameplay `toast` refusal calls with the shared ClientLocal sink. +- Add the missing move/wield failure kinds and exact item-aware compositions. +- Freeze hover-silent versus release-speaks behavior. +- Correct the vendor double-click documentation claim. + +This is small, high-confidence, and immediately turns “nothing happened” into +an actionable player explanation. + +### Slice 2 — authoritative placement ownership — COMPLETE + +- Introduce one generation-scoped pending placement record for full move, + world drop, and wield. +- Preserve canonical source ownership until the authoritative object update. +- Project source waiting/ghost and destination pending visuals separately. +- Converge success, refusal, timeout, disconnect, and late-response cleanup. +- Prove all borrowed observers see either pre-commit or committed state, never + a speculative canonical move. + +This is the most important solidity work and should receive dual review because +it crosses Runtime ownership and retained presentation. + +### Slice 3 — item-list mouse parity — COMPLETE + +- Make carried-container press/open and double-click suppression explicit. +- Add staged vendor double-click removal. +- Add staged Selling drag-to-remove and split reset/refusal. +- Restore right-click select/examine consistently across vendor list roles. + +### Slice 4 — shared legality and exact presentation — COMPLETE + +- Unify hover/release placement decisions with reason codes. +- Add the owned-coinstack toolbar branch after capturing exact retail text. +- Reconcile hard-coded local item wording with DAT-backed retail strings. + +### Automated verification — COMPLETE + +- Focused inventory/external-container/paperdoll/vendor/selection/item-use + matrix: 328 passed, 0 failed. +- Cross-controller retained-UI interaction flow: 10 passed, 0 failed. +- Complete Release build: 0 warnings, 0 errors. +- Repository hermetic lane (the exact release filter, serial execution): + 15,755 passed, 0 skipped, 0 failed across 14 test assemblies. + +The repository wrapper's project-consistency preflight explicitly excludes the +tracked deployment-only ACE comparison mods under `tools/ace-mods/`. They +compile against a separately installed ACE server and intentionally remain +outside `AcDream.slnx`; the portable product graph still owns every other +project under `src/`, `tests/`, and `tools/`. + +### Slice 5 — connected closure — DEFERRED OWNER GATE + +Run the manual matrix below against ACE using an exact built binary and retain +logs/screenshots for failures. Close #445 and #449 only after their rows pass. +Keep #443 independent unless the evidence links paperdoll rendering to an +inventory acknowledgement. + +## Connected manual matrix + +Use one normal item, one wearable item, one wieldable item, two mergeable +stacks, one side pack, a full main backpack, a full side pack, an open chest, +a locked/closed container if available, a creature/player target, and a vendor +with normal and alternate currency. + +1. Single-click each item/list type; verify selection border and exact status. +2. Right-click inventory, side-pack, external-container, paperdoll, browse, + Buying, and Selling rows; verify selection and examine. +3. Double-click ordinary usable, wearable, wieldable, and unusable items; + verify one request and correct SpewBox refusal where applicable. +4. Single- and double-click a carried side pack; verify one open action, no + redundant generic use, and stable selection. +5. Drag a full item between main pack and side pack; observe source/destination + before response, after success, and after forced rejection. +6. Fill a side pack, reject a move, free one slot, and retry immediately. +7. Fill the main pack with loose items while carrying side packs; verify item + and container capacities independently (#449). +8. Merge full and partial stacks; verify selected split amount, target + selection, source remainder, and full-target refusal text. +9. Split to an inventory container, external container, creature, ground, and + vendor; change selection while the request is pending. +10. Drop full and partial stacks to ground; verify ghost/pending behavior, + selected arriving object, rejection cleanup, and no duplicate item. +11. Pick up from ground into a nearly full destination, then retry after + freeing capacity. +12. Equip by double-click and by paperdoll drag; test clothing conflict and + weapon replacement. Verify source/paperdoll state before acknowledgement. +13. Drag full and partial stacks to vendor Selling; verify exact quantities, + temp-row replacement, totals, and #445 behavior. +14. Double-click staged Buying and Selling rows to remove them; verify SpewBox + text and state cleanup. +15. Drag a staged Selling row to remove it; repeat with a partial split selected + and verify refusal plus slider reset. +16. Complete/cancel transactions in normal and alternate currency; verify + currency balance refresh (#444) and selection/status stability. +17. Repeat representative actions while another inventory request is pending, + immediately after rejection, and immediately after reopening a container. +18. Log out/portal/re-enter with a pending or recently completed interaction; + verify the request ledger and pending projections converge to zero. + +For every refused release/action, record whether the cursor was green/red, +whether a SpewBox line appeared, the exact line, and whether canonical item +ownership changed before the server response. + +## Evidence index + +Retail research already in the tree: + +- `docs/research/deepdives/r06-items-inventory.md` +- `docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md` +- `docs/research/2026-07-13-retail-give-item-pseudocode.md` +- `docs/research/2026-07-23-retail-item-use-and-autowear-pseudocode.md` +- `docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md` +- `docs/research/2026-08-08-slice6-vendor-transactions-research.md` +- `docs/research/named-retail/acclient_2013_pseudo_c.txt` + +Primary current implementation surfaces: + +- `src/AcDream.App/UI/UiRoot.cs` +- `src/AcDream.App/UI/UiItemSlot.cs` +- `src/AcDream.App/UI/ItemInteractionController.cs` +- `src/AcDream.App/UI/Layout/InventoryController.cs` +- `src/AcDream.App/UI/Layout/ExternalContainerController.cs` +- `src/AcDream.App/UI/Layout/PaperdollController.cs` +- `src/AcDream.App/UI/Layout/SelectedObjectController.cs` +- `src/AcDream.App/UI/Layout/VendorUiController.cs` +- `src/AcDream.App/UI/AutoWieldController.cs` +- `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs` +- `src/AcDream.App/Rendering/GameWindow.cs` +- `src/AcDream.Core/Items/ItemInteractionPolicy.cs` +- `src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs` +- `src/AcDream.Runtime/Gameplay/RuntimeActionState.cs` + +## Closure statement + +The retail-backed work order is implemented through Slice 4. The code now has +one ClientLocal feedback route, request-first authoritative placement, +list-role-specific retail mouse behavior, shared placement legality, complete +move/wield failure composition, exact local-policy literals, and the exact +owned-coinstack status format. Occupied-slot AutoWield now also follows retail's +move-confirm-retry transaction instead of emitting an invented refusal. The +complete hermetic automated lane is green. No connected acceptance is claimed +here; the combined owner gate remains the final closure step, and #443 remains +an independent private-viewport residency issue. diff --git a/docs/research/2026-08-26-retail-keyboard-routing-audit.md b/docs/research/2026-08-26-retail-keyboard-routing-audit.md new file mode 100644 index 00000000..75af11e7 --- /dev/null +++ b/docs/research/2026-08-26-retail-keyboard-routing-audit.md @@ -0,0 +1,115 @@ +# Retail keyboard defaults and routing audit — 2026-08-26 + +## Verdict + +The code gate for #446 now covers all 306 user-bindable rows in the installed +Sept-2013 EoR ActionMap. Each row has a distinct `InputAction`, appears enabled +in Configure Keyboard, persists through retail-compatible named `.keymap` +profiles, and reaches +a concrete subsystem consumer. The exact installed-DAT default chord set has +zero exceptions. The remaining gate is a connected visual/behavior pass and a +fresh-process persistence check. + +The approved acdream extension is deliberately retained: mouse-wheel chase +zoom may pull back to 40 m. It does not change the retail keyboard defaults or +the keypad camera actions. + +## Oracles + +- `docs/research/named-retail/retail-default.keymap.txt` and installed + `client_portal.dat` ActionMap DID `0x26000000`: the 306 rows, default chords, + activation types, input contexts, and `ConflictingMaps` relationships. +- Installed MasterInputMaps `0x14000000` and `0x14000002`: non-bindable system + and mouse commands. +- `ClientUISystem::OnAction @0x00564B90`: Escape priority. +- `CInputManager_WIN32::GetNameFromKey_Internal @0x00687800` and + `ControlNameMapper::LoadSemantics`: displayed keyboard/mouse names. +- `ACCmdInterp::InitializeEmoteInputActionHash @0x0058B510`: all 87 emote + action-to-motion mappings. +- `CPlayerSystem::SelectNext @0x0055F9A0`: selection-cycle filtering and + opened-corpse behavior. + +## Exact ActionMap coverage + +| Retail input map | Rows | Consumer | +|---|---:|---| +| Movement | 14 | Runtime movement owner, including four postures | +| Camera + alternate camera | 22 | Held camera input, presets, alternate-scope modifier, instant mouse look | +| Combat + melee + missile + magic | 32 | Runtime combat attack owner and spellcasting controller, including spell slots 1–12 | +| Emotes | 87 | Exact retail raw-motion table and Runtime `ExecuteMotion` | +| Item selection | 26 | Selection controller/query and canonical inventory interaction state | +| UI | 42 | Retained panels, screenshot, help/plugin result, logout, and selection commands | +| Chat + chat-entry toggle | 7 | Retained chat entry/reply/command routes | +| Quickslots | 28 | Toolbar use/select/create routes, including slots 10–18 | +| Character settings | 48 | Exact `CharacterOptionId` bit toggle through Runtime | +| **Total** | **306** | **306 distinct live identities** | + +The low MasterInputMap entries such as bare Escape and raw mouse event +commands are intentionally not Configure Keyboard rows in retail and are not +counted among the 306. Unknown rows from a future DAT can still round-trip in +the compatibility sibling store, but the installed EoR DAT has no such row and +shows no dimmed/store-only keyboard entry. + +## Behavior completed + +- Defaults are an exact installed-DAT transcription, including bare + `LeftShift`; device, modifier, activation, and scope all match. +- Primary and alternate camera maps remain distinct rebind targets even where + retail reuses an action id. The alternate modifier changes the active camera + scope without aliasing saved bindings. +- Same physical chord may fire each distinct retail action allowed by the + ActionMap. In particular, the authored Alt+1..4 chat/UI and quickslot rows + multicast instead of one silently replacing the other. +- Rebind conflicts use the DAT `ConflictingMaps` table. The shared melee, + missile, and magic key cluster remains legal; true conflicts still prompt. +- Capture accepts keyboard keys, modifier-only bindings, and mouse buttons. + Physical modifier self-bits are normalized, so binding LeftShift does not + accidentally become Shift+LeftShift. Mouse button names use retail's + `DIMOFS_BUTTON0..7` semantics table. Unsupported joystick and left/right + mouse inputs keep the instruction dialog open and re-arm capture. +- Setting the chord already present on the same row is a no-op. New chords use + retail's dense two-slot insertion rule, and conflicts use priority dialogs + with the exact installed-DAT singular/plural and non-bindable text. +- Apply/OK, Revert, Defaults, Cancel, explicit unbinding, schema migration, + and startup persistence are covered. Revert is enabled only while dirty; + OK avoids rewriting an unchanged file. +- Load File and Save As use retail's type-7 menu/type-5 text-entry dialogs, + PFile bracket-text grammar, filename normalization, overwrite/read-only + handling, `Documents\Asheron's Call\*.keymap` directory, selected-profile + preference, startup load, and graceful-shutdown rewrite. The portable JSON + file remains only as an acdream-host-command compatibility mirror. +- Escape follows retail's priority: finish jump charge, release focused UI, + stop movement/repeat attack, cancel target mode, clear selection, then + toggle the authored Gameplay Options page. It never exits player mode or + exposes the orbit/developer camera. Shift+Escape reaches the normal logout + gate. +- Selection cycling applies retail's containment, cloaking, radar, attackable, + fellow, vendor, environment, combat-mode, and opened-corpse rules. + Opened-corpse history lives for the session and retires on object deletion. +- Screenshot, help, and plugin actions are consumed. Missing separately + shipped retail help/plugin surfaces report an honest chat/system result + rather than doing nothing. + +## Automated verification + +- App: 6,413/6,413 passed. +- Core: 4,713/4,713 passed. +- Runtime: 1,849/1,849 passed. +- UI.Abstractions: 879/879 passed. +- Installed-DAT identity/default conformance and the authored Configure + Keyboard mount pin all 306 rows. + +The `.keymap` codec parses the committed real retail file and round-trips all +306 user-bindable identities, including low-bit Shift/Ctrl/Alt/Win modifiers, +DirectInput controls, fixed Escape/system/edit/pointer maps, and the 48 +CharacterOption action names. AP-202 is retired. The connected gate could not +be run because no local ACE endpoint was listening on UDP port 9000. + +## Connected acceptance gate + +Use the installed EoR DATs and the normal owner-gate pak. In Configure +Keyboard, verify that all rows are enabled and that a key, modifier-only chord, +and mouse button can each be rebound. Exercise representative movement, +camera, melee/missile/magic, emote, selection, panel, chat, quickslot, and +character-option actions. Verify conflict prompt, Cancel, Revert, Defaults, +Apply, and OK, then restart the process and confirm the applied bindings remain. diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 116ff7ba..278737cf 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -96,7 +96,8 @@ internal sealed record InteractionRetainedUiDependencies( Func CurrentCalendar, AcDream.App.Rendering.Packs.RenderPackCatalogSource? RenderPackCatalog = null, Func? - RenderPackDiagnostics = null) + RenderPackDiagnostics = null, + string? ScreenshotsDirectory = null) { public RuntimeActionState Actions => Runtime.ActionOwner; @@ -388,9 +389,15 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory container, placement, amount), + sendStackableMerge: (source, target, amount) => + session.CurrentSession?.SendStackableMerge(source, target, amount), requestExternalContainer: guid => { - d.Inventory.ExternalContainers.RequestOpen(guid); + ClientObject? container = d.Inventory.Objects.Get(guid); + bool isCorpse = container is not null + && ((PublicWeenieFlags)(container.PublicWeenieBitfield ?? 0u) + & PublicWeenieFlags.Corpse) != 0; + d.Inventory.ExternalContainers.RequestOpen(guid, isCorpse); }, requestUse: selection.RequestUse, // Slice 6.3: ItemInteractionController.TryBuy owns the @@ -663,16 +670,20 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory CharacterKey: () => d.Settings.ActiveToonKey, ScreenSize: () => (d.Window.Size.X, d.Window.Size.Y)); void ProbeLog(string message) => d.Log("[UI-PROBE] " + message); - FrameScreenshotController? screenshots = null; - if (d.Options.UiProbeEnabled - && d.Options.AutomationArtifactDirectory is { } artifactDirectory) - { - screenshots = new FrameScreenshotController( - d.BackbufferReader, - Path.Combine(artifactDirectory, "screenshots"), - ProbeLog, - d.RenderPackDiagnostics); - } + string screenshotDirectory = + d.Options.UiProbeEnabled + && d.Options.AutomationArtifactDirectory is { } artifactDirectory + ? Path.Combine(artifactDirectory, "screenshots") + : !string.IsNullOrWhiteSpace(d.ScreenshotsDirectory) + ? d.ScreenshotsDirectory + : Path.Combine( + Path.GetDirectoryName(d.KeyBindingsFilePath)!, + "screenshots"); + var screenshots = new FrameScreenshotController( + d.BackbufferReader, + screenshotDirectory, + ProbeLog, + d.RenderPackDiagnostics); checkpoint(InteractionRetainedUiCompositionPoint.UiProbeCreated); var assets = new RetailUiAssets( @@ -1157,11 +1168,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory late.GameRuntime.CharacterSelectionConfirmDelete, late.GameRuntime.CharacterSelectionRestore, late.GameRuntime.CharacterSelectionCancel, - // Campaign LA gate round 2 finding 1: the SAME - // window-close path GameplayInputCommandController's - // Escape fallback uses (IGameplayWindowCommands.Close - // /GameplayWindowCommands wrap this same d.Window.Close - // delegate) — no separate exit path. + // Campaign LA gate round 2 finding 1: the character + // selection screen's Exit button uses the ordinary host + // close path. Gameplay Escape is independent: retail + // clears selection or toggles the Gameplay Options page. d.Window.Close), // Campaign CC slice CC4: same late-bound generation-capturing // seam as CharacterSelection above. RequestExit here is a @@ -1203,7 +1213,24 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory RandomizeAppearance: late.GameRuntime.CharacterCreationRandomizeAppearance, RandomizeClothing: late.GameRuntime.CharacterCreationRandomizeClothing, GetSkillScore: chargenSkillScoreResolver.Resolve, - OpenOnStart: d.Options.OpenCharacterCreationOnStart)); + OpenOnStart: d.Options.OpenCharacterCreationOnStart), + CaptureScreenshot: () => + { + if (screenshots.TryRequestRetailScreenshot( + out string path, + out string error)) + { + d.Communication.AddText( + $"Screenshot saved to {path}", + RetailLogTextType.ClientLocal); + } + else + { + d.Communication.AddText( + $"Screenshot failed: {error}", + RetailLogTextType.ClientLocal); + } + }); RetailUiRuntime runtime = lease.Mount( () => RetailUiRuntime.CreateUninitialized(bindings)); checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted); diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index aa9391f8..3ba05a6a 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -63,6 +63,7 @@ internal sealed record LivePresentationDependencies( CellVisibility CellVisibility, LiveWorldOriginState WorldOrigin, LocalPlayerIdentityState PlayerIdentity, + ChaseCameraInputState ChaseCameraInput, PointerPositionState PointerPosition, PlayerApproachCompletionState PlayerApproachCompletions, GameRenderResourceLifetime RenderResourceLifetime, @@ -808,7 +809,12 @@ internal sealed class LivePresentationCompositionPhase d.RetailAlphaQueue, alphaScratchBudgets.DispatcherBytes, foundation.TerrainAtlas?.BuildingDetailTexture ?? default, - () => d.Settings.DisplayPreview.BuildingDetailTextures), + () => d.Settings.DisplayPreview.BuildingDetailTextures, + serverGuid => serverGuid != 0u + && serverGuid == d.PlayerIdentity.ServerGuid + ? d.ChaseCameraInput.Retail?.PlayerTranslucency + ?? (d.ChaseCameraInput.Legacy?.IsInHead == true ? 1f : 0f) + : 0f), static value => value.Dispose()); var selectionQuery = new WorldSelectionQuery( liveEntities, @@ -845,7 +851,11 @@ internal sealed class LivePresentationCompositionPhase localEntityId => d.EffectPoses.TryGetRootPose(localEntityId, out Matrix4x4 childRoot) ? childRoot - : null); + : null, + hasOpenedCorpse: + d.Runtime.InventoryOwner.ExternalContainers.HasCorpseBeenOpened, + combatMode: () => d.Runtime.ActionOwner.Combat.CurrentMode, + isFellow: guid => d.Runtime.Fellowship.TryGetMember(guid, out _)); var radarSnapshotProvider = new RadarSnapshotProvider( d.EntityObjects.Objects, liveEntities, @@ -876,7 +886,12 @@ internal sealed class LivePresentationCompositionPhase () => d.PlayerController.Controller, d.PlayerApproachCompletions), d.Toast, - d.PlayerApproachCompletions); + d.PlayerApproachCompletions, + splitStack: guid => + interaction.RetainedUi?.Runtime.SelectedObjectController? + .FocusSplitStackEntry(guid) ?? false, + fellowshipMembers: () => + d.Runtime.Fellowship.GetMembers().Select(static member => member.Guid)); selectionInteractionSource.Bind(selectionInteractions); bindings.Adopt( "world selection", diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 5f934e36..2317eb30 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -1169,6 +1169,7 @@ internal sealed class SessionPlayerCompositionPhase live.SelectionInteractions), new LiveSessionWorldRuntime( content.Dats, + d.DatLock, content.Audio?.Engine is { } sessionAudioEngine ? new AcDream.App.Audio.WorldAudioSessionGate( sessionAudioEngine, @@ -1279,14 +1280,10 @@ internal sealed class SessionPlayerCompositionPhase new RetainedGameplayWindowCommands( interaction.RetainedUi?.Runtime), runtimeDiagnostics, - new PlayerModeGameplayCommands( - d.PlayerMode, - playerMode), + new PlayerModeGameplayCommands(playerMode), new ItemTargetModeCommands(interaction.ItemInteraction), - new GameplayCameraModeCommands(host.CameraController), gameRuntime, gameRuntime.Combat, - new GameplayWindowCommands(d.Window.Close), toggleAudioMute: content.Audio?.Engine is { } audioEngine ? () => { @@ -1305,6 +1302,7 @@ internal sealed class SessionPlayerCompositionPhase gameRuntime, gameRuntime.Selection, gameRuntime.MovementCommands, + gameRuntime.CharacterCommands, commands); GameplayInputActionRouter gameplayActions = GameplayInputActionRouter.Create( diff --git a/src/AcDream.App/Composition/SettingsDevToolsComposition.cs b/src/AcDream.App/Composition/SettingsDevToolsComposition.cs index 15e7df79..526df5f3 100644 --- a/src/AcDream.App/Composition/SettingsDevToolsComposition.cs +++ b/src/AcDream.App/Composition/SettingsDevToolsComposition.cs @@ -18,8 +18,8 @@ namespace AcDream.App.Composition; /// UiHost/UiRoot tree — D1) instead of a new /// IPanelRenderer implementation, and its OP9 closeout retired the /// unrendered ImGui-era SettingsPanel/SettingsVM outright. Keybind remapping -/// is Campaign OP slice OP8's Configure Keyboard screen, persisting to -/// keybinds.json (not retail's .keymap format — register row AP-202). +/// is Campaign OP slice OP8's Configure Keyboard screen, persisting retail +/// *.keymap profiles with keybinds.json as the host-command mirror. /// internal sealed record SettingsDevToolsResult( AcDream.UI.Abstractions.Settings.QualitySettings ResolvedQuality) diff --git a/src/AcDream.App/Diagnostics/FrameScreenshotController.cs b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs index a2588d5b..9d1d0396 100644 --- a/src/AcDream.App/Diagnostics/FrameScreenshotController.cs +++ b/src/AcDream.App/Diagnostics/FrameScreenshotController.cs @@ -63,6 +63,36 @@ internal sealed class FrameScreenshotController return true; } + /// + /// Queues the first free retail-style screenshot name. Retail scans + /// ScreenShot00000.jpg through ScreenShot99999.jpg beside + /// its preferences file; acdream keeps the exact stem/numbering while + /// writing lossless PNGs in the portable screenshots directory. + /// + public bool TryRequestRetailScreenshot(out string path, out string error) + { + for (int index = 0; index < 100_000; index++) + { + string name = $"ScreenShot{index:D5}"; + string candidate = Path.Combine(_directory, name + ".png"); + if (File.Exists(candidate) || _status.ContainsKey(name)) + continue; + + if (TryRequest(name, out error)) + { + path = candidate; + return true; + } + + path = string.Empty; + return false; + } + + path = string.Empty; + error = "all retail screenshot names ScreenShot00000 through ScreenShot99999 are in use"; + return false; + } + public bool IsComplete(string name) => _status.TryGetValue(name, out CaptureStatus? status) && status.State == CaptureState.Complete; diff --git a/src/AcDream.App/Input/CameraPointerInputController.cs b/src/AcDream.App/Input/CameraPointerInputController.cs index aa2672da..9104323f 100644 --- a/src/AcDream.App/Input/CameraPointerInputController.cs +++ b/src/AcDream.App/Input/CameraPointerInputController.cs @@ -316,6 +316,88 @@ internal sealed class CameraPointerInputController } } + public bool HandleCameraAction( + InputAction action, + ActivationType activation) + { + if (activation != ActivationType.Press + || !_playerMode.IsPlayerMode + || !_camera.IsChaseMode) + { + return false; + } + + bool handled = action is + InputAction.CameraViewDefault + or InputAction.CameraAlternateViewDefault + or InputAction.CameraViewFirstPerson + or InputAction.CameraAlternateViewFirstPerson + or InputAction.CameraViewLookDown + or InputAction.CameraAlternateViewLookDown + or InputAction.CameraViewMapMode + or InputAction.CameraAlternateViewMapMode; + if (!handled) + return false; + + ApplyCameraPreset(_chase.Retail, action); + ApplyCameraPreset(_chase.Legacy, action); + return true; + } + + private static void ApplyCameraPreset( + RetailChaseCamera? camera, + InputAction action) + { + if (camera is null) + return; + switch (action) + { + case InputAction.CameraViewDefault: + case InputAction.CameraAlternateViewDefault: + camera.SetRetailDefaultView(); + break; + case InputAction.CameraViewFirstPerson: + case InputAction.CameraAlternateViewFirstPerson: + camera.SetRetailFirstPersonView(); + break; + case InputAction.CameraViewLookDown: + case InputAction.CameraAlternateViewLookDown: + camera.ToggleRetailLookDownView(); + break; + case InputAction.CameraViewMapMode: + case InputAction.CameraAlternateViewMapMode: + camera.ToggleRetailMapModeView(); + break; + } + } + + private static void ApplyCameraPreset( + ChaseCamera? camera, + InputAction action) + { + if (camera is null) + return; + switch (action) + { + case InputAction.CameraViewDefault: + case InputAction.CameraAlternateViewDefault: + camera.SetRetailDefaultView(); + break; + case InputAction.CameraViewFirstPerson: + case InputAction.CameraAlternateViewFirstPerson: + camera.SetRetailFirstPersonView(); + break; + case InputAction.CameraViewLookDown: + case InputAction.CameraAlternateViewLookDown: + camera.ToggleRetailLookDownView(); + break; + case InputAction.CameraViewMapMode: + case InputAction.CameraAlternateViewMapMode: + camera.ToggleRetailMapModeView(); + break; + } + } + public string AdjustSensitivity(float factor) { string mode; diff --git a/src/AcDream.App/Input/DispatcherCameraInputSource.cs b/src/AcDream.App/Input/DispatcherCameraInputSource.cs index dae50088..94a6ea8f 100644 --- a/src/AcDream.App/Input/DispatcherCameraInputSource.cs +++ b/src/AcDream.App/Input/DispatcherCameraInputSource.cs @@ -15,7 +15,9 @@ internal readonly record struct ChaseCameraAdjustmentInput( bool ZoomIn, bool ZoomOut, bool Raise, - bool Lower); + bool Lower, + bool RotateLeft, + bool RotateRight); internal interface ICameraFrameInputSource { @@ -71,9 +73,21 @@ internal sealed class DispatcherCameraInputSource : ICameraFrameInputSource return default; return new ChaseCameraAdjustmentInput( - dispatcher.IsActionHeld(InputAction.CameraZoomIn), - dispatcher.IsActionHeld(InputAction.CameraZoomOut), - dispatcher.IsActionHeld(InputAction.CameraRaise), - dispatcher.IsActionHeld(InputAction.CameraLower)); + dispatcher.IsActionHeld(InputAction.CameraZoomIn) + || dispatcher.IsActionHeld(InputAction.CameraMoveToward) + || dispatcher.IsActionHeld(InputAction.CameraAlternateMoveToward), + dispatcher.IsActionHeld(InputAction.CameraZoomOut) + || dispatcher.IsActionHeld(InputAction.CameraMoveAway) + || dispatcher.IsActionHeld(InputAction.CameraAlternateMoveAway), + dispatcher.IsActionHeld(InputAction.CameraRaise) + || dispatcher.IsActionHeld(InputAction.CameraRotateUp) + || dispatcher.IsActionHeld(InputAction.CameraAlternateRotateUp), + dispatcher.IsActionHeld(InputAction.CameraLower) + || dispatcher.IsActionHeld(InputAction.CameraRotateDown) + || dispatcher.IsActionHeld(InputAction.CameraAlternateRotateDown), + dispatcher.IsActionHeld(InputAction.CameraRotateLeft) + || dispatcher.IsActionHeld(InputAction.CameraAlternateRotateLeft), + dispatcher.IsActionHeld(InputAction.CameraRotateRight) + || dispatcher.IsActionHeld(InputAction.CameraAlternateRotateRight)); } } diff --git a/src/AcDream.App/Input/GameplayInputActionRouter.cs b/src/AcDream.App/Input/GameplayInputActionRouter.cs index 9840e0b1..303b91b0 100644 --- a/src/AcDream.App/Input/GameplayInputActionRouter.cs +++ b/src/AcDream.App/Input/GameplayInputActionRouter.cs @@ -3,6 +3,7 @@ using AcDream.App.Rendering; using AcDream.App.UI; using AcDream.Core.Combat; using AcDream.Runtime; +using AcDream.Runtime.Gameplay; using AcDream.UI.Abstractions.Input; namespace AcDream.App.Input; @@ -14,6 +15,8 @@ internal interface IGameplayInputActionSurface void RemoveFired(Action callback); void SetCombatScope(InputScope? scope); + + void SetCameraAlternateScope(bool active); } internal sealed class DispatcherGameplayInputActionSurface(InputDispatcher dispatcher) @@ -30,6 +33,9 @@ internal sealed class DispatcherGameplayInputActionSurface(InputDispatcher dispa public void SetCombatScope(InputScope? scope) => _dispatcher.SetCombatScope(scope); + + public void SetCameraAlternateScope(bool active) => + _dispatcher.SetCameraAlternateScope(active); } internal interface ICombatModeEventSurface @@ -66,6 +72,8 @@ internal interface IGameplayInputPriorityTargets bool HandleRetainedUiAction(InputAction action); + bool HandleCharacterOptionAction(InputAction action); + bool HandleSelectionAction(InputAction action); bool HandlePressedMovementAction(InputAction action); @@ -87,6 +95,7 @@ internal sealed class RuntimeGameplayInputPriorityTargets private readonly IGameRuntimeView _runtimeView; private readonly IRuntimeSelectionCommands _runtimeSelection; private readonly IRuntimeMovementCommands _runtimeMovement; + private readonly IRuntimeCharacterCommands _runtimeCharacter; private readonly IGameplayInputCommandTarget _commands; public RuntimeGameplayInputPriorityTargets( @@ -97,6 +106,7 @@ internal sealed class RuntimeGameplayInputPriorityTargets IGameRuntimeView runtimeView, IRuntimeSelectionCommands runtimeSelection, IRuntimeMovementCommands runtimeMovement, + IRuntimeCharacterCommands runtimeCharacter, IGameplayInputCommandTarget commands) { _frame = frame ?? throw new ArgumentNullException(nameof(frame)); @@ -109,11 +119,14 @@ internal sealed class RuntimeGameplayInputPriorityTargets ?? throw new ArgumentNullException(nameof(runtimeSelection)); _runtimeMovement = runtimeMovement ?? throw new ArgumentNullException(nameof(runtimeMovement)); + _runtimeCharacter = runtimeCharacter + ?? throw new ArgumentNullException(nameof(runtimeCharacter)); _commands = commands ?? throw new ArgumentNullException(nameof(commands)); } public bool HandlePointerAction(InputAction action, ActivationType activation) => - _frame.HandlePointerAction(action, activation); + _frame.HandlePointerAction(action, activation) + || _pointer.HandleCameraAction(action, activation); public void HandleScroll(InputAction action) => _pointer.HandleScroll(action); @@ -122,10 +135,74 @@ internal sealed class RuntimeGameplayInputPriorityTargets _frame.HandleCombatAction(action, activation); public bool HandleRetainedUiAction(InputAction action) => - _retainedUi?.HandleInputAction(action) == true; + FinishJumpBeforeUi(action) + || _retainedUi?.HandleInputAction(action) == true; + + public bool HandleCharacterOptionAction(InputAction action) + { + if (!RetailActionIdentityTable.TryGetCharacterOptionId( + action, + out uint optionId) + || !CharacterOptionTable.TryGet( + optionId, + out CharacterOptionTableEntry entry)) + { + return false; + } + + RuntimeCharacterOptionsSnapshot options = + _runtimeView.Character.Snapshot.Options; + uint word = entry.IsOptions1 ? options.Options1 : options.Options2; + bool current = (word & entry.Mask) != 0u; + _runtimeCharacter.SetSingleOption( + _runtimeView.Generation, + optionId, + !current); + return true; + } + + private bool FinishJumpBeforeUi(InputAction action) + { + RuntimeMovementCommand? command = ResolveEscapeMovementCommand( + action, + _runtimeView.Movement.IsStandingStill, + _runtimeView.Movement.JumpCharge, + _runtimeView.Actions.Snapshot.CombatAttack); + if (command != RuntimeMovementCommand.FinishJump) + { + return false; + } + + _runtimeMovement.Execute( + _runtimeView.Generation, + command.Value); + return true; + } public bool HandleSelectionAction(InputAction action) { + if (action == InputAction.EscapeKey) + { + IRuntimeMovementView movement = _runtimeView.Movement; + RuntimeCombatAttackSnapshot attack = _runtimeView.Actions.Snapshot + .CombatAttack; + RuntimeMovementCommand? escapeCommand = + ResolveEscapeMovementCommand( + action, + movement.IsStandingStill, + movement.JumpCharge, + attack); + if (escapeCommand == RuntimeMovementCommand.StopCompletely) + { + _runtimeMovement.Execute( + _runtimeView.Generation, + escapeCommand.Value); + if (attack.RepeatAttackInProgress) + _frame.AbortAutomaticAttack(); + return true; + } + } + RuntimeSelectionCommand? command = action switch { InputAction.SelectionClosestMonster => @@ -149,15 +226,32 @@ internal sealed class RuntimeGameplayInputPriorityTargets return _selection?.HandleInputAction(action) == true; } + internal static RuntimeMovementCommand? ResolveEscapeMovementCommand( + InputAction action, + bool isStandingStill, + in AcDream.Runtime.Gameplay.JumpChargeSnapshot jumpCharge, + in RuntimeCombatAttackSnapshot attack) + { + if (action != InputAction.EscapeKey) + return null; + if (jumpCharge.IsCharging) + return RuntimeMovementCommand.FinishJump; + if (!isStandingStill || attack.RepeatAttackInProgress) + return RuntimeMovementCommand.StopCompletely; + return null; + } + public bool HandlePressedMovementAction(InputAction action) { - RuntimeMovementCommand? command = action switch + if (RetailEmoteMotionTable.TryGetMotion(action, out uint motion)) { - InputAction.MovementRunLock => - RuntimeMovementCommand.ToggleRunLock, - InputAction.MovementStop => RuntimeMovementCommand.Stop, - _ => null, - }; + _runtimeMovement.ExecuteMotion( + _runtimeView.Generation, + motion); + return true; + } + + RuntimeMovementCommand? command = ResolvePressedMovementCommand(action); if (command is { } typed) { _runtimeMovement.Execute(_runtimeView.Generation, typed); @@ -167,6 +261,18 @@ internal sealed class RuntimeGameplayInputPriorityTargets return _frame.HandlePressedMovementAction(action); } + internal static RuntimeMovementCommand? ResolvePressedMovementCommand( + InputAction action) => action switch + { + InputAction.MovementRunLock => RuntimeMovementCommand.ToggleRunLock, + InputAction.MovementStop => RuntimeMovementCommand.Stop, + InputAction.Ready => RuntimeMovementCommand.Ready, + InputAction.Sitting => RuntimeMovementCommand.Sit, + InputAction.Crouch => RuntimeMovementCommand.Crouch, + InputAction.Sleeping => RuntimeMovementCommand.Sleep, + _ => null, + }; + public void HandleCommand(InputAction action) => _commands.Handle(action); } @@ -298,6 +404,14 @@ internal sealed class GameplayInputActionRouter : IDisposable { _log($"[input] {action} {activation}"); + if (action == InputAction.CameraActivateAlternateMode) + { + if (activation == ActivationType.Press) + _actions.SetCameraAlternateScope(true); + else if (activation == ActivationType.Release) + _actions.SetCameraAlternateScope(false); + } + if (_targets.HandlePointerAction(action, activation)) return; @@ -320,6 +434,8 @@ internal sealed class GameplayInputActionRouter : IDisposable if (_targets.HandleRetainedUiAction(action)) return; + if (_targets.HandleCharacterOptionAction(action)) + return; if (_targets.HandleSelectionAction(action)) return; if (_targets.HandlePressedMovementAction(action)) diff --git a/src/AcDream.App/Input/GameplayInputCommandController.cs b/src/AcDream.App/Input/GameplayInputCommandController.cs index f3fbb3a1..246c52b4 100644 --- a/src/AcDream.App/Input/GameplayInputCommandController.cs +++ b/src/AcDream.App/Input/GameplayInputCommandController.cs @@ -1,6 +1,5 @@ using AcDream.App.Combat; using AcDream.App.Diagnostics; -using AcDream.App.Rendering; using AcDream.App.UI; using AcDream.Runtime; using AcDream.UI.Abstractions.Input; @@ -24,6 +23,12 @@ internal interface IRetainedGameplayWindowCommands /// RetailUiRuntime.BindToolbarPanelButtons. /// void ToggleOptionsPanel(); + + void ToggleGameplayOptionsPage(); + + void FocusChatEntry(); + + void LogOutCharacter(); } internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime) @@ -39,35 +44,31 @@ internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime) public void ToggleOptionsPanel() => _runtime?.ToggleWindow(WindowNames.Options); + + public void ToggleGameplayOptionsPage() => + _runtime?.ToggleGameplayOptionsPage(); + + public void FocusChatEntry() => _runtime?.FocusChatEntry(); + + public void LogOutCharacter() => _runtime?.LogOutCharacter(); } internal interface IPlayerModeGameplayCommands { - bool IsPlayerMode { get; } - void ToggleFlyOrChase(); void TogglePlayerMode(); - - void ExitPlayerMode(); } -internal sealed class PlayerModeGameplayCommands( - ILocalPlayerModeSource mode, - PlayerModeController controller) : IPlayerModeGameplayCommands +internal sealed class PlayerModeGameplayCommands(PlayerModeController controller) + : IPlayerModeGameplayCommands { - private readonly ILocalPlayerModeSource _mode = mode - ?? throw new ArgumentNullException(nameof(mode)); private readonly PlayerModeController _controller = controller ?? throw new ArgumentNullException(nameof(controller)); - public bool IsPlayerMode => _mode.IsPlayerMode; - public void ToggleFlyOrChase() => _controller.ToggleFlyOrChase(); public void TogglePlayerMode() => _controller.Toggle(); - - public void ExitPlayerMode() => _controller.Exit(); } internal interface IItemTargetModeCommands @@ -88,37 +89,6 @@ internal sealed class ItemTargetModeCommands(ItemInteractionController items) public void CancelTargetMode() => _items.CancelTargetMode(); } -internal interface IGameplayCameraModeCommands -{ - bool IsFlyMode { get; } - - void ExitFlyMode(); -} - -internal sealed class GameplayCameraModeCommands(CameraController camera) - : IGameplayCameraModeCommands -{ - private readonly CameraController _camera = camera - ?? throw new ArgumentNullException(nameof(camera)); - - public bool IsFlyMode => _camera.IsFlyMode; - - public void ExitFlyMode() => _camera.ToggleFly(); -} - -internal interface IGameplayWindowCommands -{ - void Close(); -} - -internal sealed class GameplayWindowCommands(Action close) : IGameplayWindowCommands -{ - private readonly Action _close = close - ?? throw new ArgumentNullException(nameof(close)); - - public void Close() => _close(); -} - internal interface IGameplayInputCommandTarget { bool Handle(InputAction action); @@ -135,10 +105,8 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg private readonly IRuntimeDiagnosticCommands _diagnostics; private readonly IPlayerModeGameplayCommands _playerMode; private readonly IItemTargetModeCommands _targetMode; - private readonly IGameplayCameraModeCommands _camera; private readonly IGameRuntimeView _runtimeView; private readonly IRuntimeCombatCommands _combat; - private readonly IGameplayWindowCommands _window; private readonly Action? _toggleAudioMute; public GameplayInputCommandController( @@ -146,21 +114,17 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg IRuntimeDiagnosticCommands diagnostics, IPlayerModeGameplayCommands playerMode, IItemTargetModeCommands targetMode, - IGameplayCameraModeCommands camera, IGameRuntimeView runtimeView, IRuntimeCombatCommands combat, - IGameplayWindowCommands window, Action? toggleAudioMute = null) { _retained = retained ?? throw new ArgumentNullException(nameof(retained)); _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); _playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode)); _targetMode = targetMode ?? throw new ArgumentNullException(nameof(targetMode)); - _camera = camera ?? throw new ArgumentNullException(nameof(camera)); _runtimeView = runtimeView ?? throw new ArgumentNullException(nameof(runtimeView)); _combat = combat ?? throw new ArgumentNullException(nameof(combat)); - _window = window ?? throw new ArgumentNullException(nameof(window)); _toggleAudioMute = toggleAudioMute; } @@ -206,10 +170,12 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg _playerMode.TogglePlayerMode(); return true; case InputAction.ToggleChatEntry: - // OP9: IDevToolsGameplayCommands.FocusChatInput() retired — - // same shape as AcdreamToggleDebugPanel above (its ImGui - // ChatPanel target was already gone). Tab is still consumed - // here, matching the prior no-op's "handled" contract. + case InputAction.EnterChatMode: + // Physical Tab/Enter are normally consumed by UiRoot before + // the dispatcher. This semantic route is what makes a rebound + // key and headless/UI automation reach that same retained + // chat field. + _retained.FocusChatEntry(); return true; case InputAction.ToggleOptionsPanel: // Campaign OP slice OP3 (D1): F11 opens the RETAIL Options @@ -227,6 +193,9 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg _runtimeView.Generation, RuntimeCombatCommand.ToggleMode); return true; + case InputAction.LOGOUT: + _retained.LogOutCharacter(); + return true; case InputAction.EscapeKey: HandleEscape(); return true; @@ -239,9 +208,7 @@ internal sealed class GameplayInputCommandController : IGameplayInputCommandTarg { if (_targetMode.IsAnyTargetModeActive) _targetMode.CancelTargetMode(); - else if (_playerMode.IsPlayerMode) - _playerMode.ExitPlayerMode(); else - _window.Close(); + _retained.ToggleGameplayOptionsPage(); } } diff --git a/src/AcDream.App/Input/GameplayInputFrameController.cs b/src/AcDream.App/Input/GameplayInputFrameController.cs index f5b34fd0..4f87b484 100644 --- a/src/AcDream.App/Input/GameplayInputFrameController.cs +++ b/src/AcDream.App/Input/GameplayInputFrameController.cs @@ -9,6 +9,7 @@ internal interface ICombatInputFrameController { void Tick(); void HandleMovementInput(InputAction action, ActivationType activation); + void AbortAutomaticAttack(); bool HandleInputAction(InputAction action, ActivationType activation); } @@ -38,6 +39,11 @@ internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameControlle RuntimeInputActivation.Press)); } + public void AbortAutomaticAttack() => + _owner.HandleCommand(new RuntimeCombatAttackInput( + RuntimeCombatAttackCommand.AbortForMovement, + RuntimeInputActivation.Press)); + public bool HandleInputAction(InputAction action, ActivationType activation) { RuntimeCombatAttackCommand? command = action switch @@ -52,16 +58,37 @@ internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameControlle RuntimeCombatAttackCommand.DecreasePower, InputAction.CombatIncreaseAttackPower => RuntimeCombatAttackCommand.IncreasePower, + InputAction.CombatDecreaseMissileAccuracy => + RuntimeCombatAttackCommand.DecreasePower, + InputAction.CombatIncreaseMissileAccuracy => + RuntimeCombatAttackCommand.IncreasePower, + InputAction.CombatAimLow => + RuntimeCombatAttackCommand.LowAttack, + InputAction.CombatAimMedium => + RuntimeCombatAttackCommand.MediumAttack, + InputAction.CombatAimHigh => + RuntimeCombatAttackCommand.HighAttack, _ => null, }; if (command is null) return false; + // A retail Hold binding emits Press once, Hold every input frame, then + // Release on key-up. RuntimeCombatAttackState already measures the + // Press-to-Release interval; forwarding the repeated Hold pulse as a + // Release made Delete/End/PageDown attack on the first frame instead + // of charging until the player released the key. + if (activation == ActivationType.Hold) + return true; + return _owner.HandleCommand(new RuntimeCombatAttackInput( command.Value, - activation == ActivationType.Press - ? RuntimeInputActivation.Press - : RuntimeInputActivation.Release)); + activation switch + { + ActivationType.Press => RuntimeInputActivation.Press, + ActivationType.Release => RuntimeInputActivation.Release, + _ => RuntimeInputActivation.Press, + })); } } @@ -114,6 +141,8 @@ internal sealed class GameplayInputFrameController public bool HandlePressedMovementAction(InputAction action) => _movement.HandlePressedAction(action); + public void AbortAutomaticAttack() => _combat.AbortAutomaticAttack(); + public void QueueRawMouseDelta(float dx, float dy) => _mouseLook?.QueueRawDelta(dx, dy); diff --git a/src/AcDream.App/Input/MouseLookController.cs b/src/AcDream.App/Input/MouseLookController.cs index 998969ae..d3b28f79 100644 --- a/src/AcDream.App/Input/MouseLookController.cs +++ b/src/AcDream.App/Input/MouseLookController.cs @@ -149,7 +149,9 @@ internal sealed class MouseLookController : IMouseLookInputFrameController return true; } - if (action != InputAction.CameraInstantMouseLook) + if (action is not ( + InputAction.CameraInstantMouseLook + or InputAction.CameraActivateAlternateMode)) return false; if (activation == ActivationType.Press) diff --git a/src/AcDream.App/Input/RetailEmoteMotionTable.cs b/src/AcDream.App/Input/RetailEmoteMotionTable.cs new file mode 100644 index 00000000..0e7979d4 --- /dev/null +++ b/src/AcDream.App/Input/RetailEmoteMotionTable.cs @@ -0,0 +1,129 @@ +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Input; + +/// +/// Verbatim Sept-2013 ACCmdInterp::InitializeEmoteInputActionHash +/// (0x0058B510). ACCmdInterp::OnAction +/// (0x0058B370) resolves one of these input actions and submits the +/// corresponding raw motion through SetMotion with start=true. +/// +internal static class RetailEmoteMotionTable +{ + private const uint EmoteInputMap = 0x10000006u; + private const uint FirstEmoteAction = 0x10000098u; + + // Action ids 0x10000098..0x100000EE are contiguous in retail's ActionMap. + // Values come from the named Motion_* globals used by the initializer. + private static readonly uint[] Motions = + [ + 0x43000118u, // AFKState + 0x13000088u, // Akimbo + 0x420000F9u, // ATOYOT + 0x430000F2u, // AkimboState + 0x43000146u, // AtEaseState + 0x1300007Au, // Beckon + 0x1300007Bu, // BeSeeingYou + 0x1300007Cu, // BlowKiss + 0x1300007Du, // BowDeep + 0x430000ECu, // BowDeepState + 0x1300004Cu, // Cheer + 0x1300007Eu, // ClapHands + 0x430000EDu, // ClapHandsState + 0x13000091u, // Cringe + 0x430000EEu, // CrossArmsState + 0x1300007Fu, // Cry + 0x43000117u, // CurtseyState + 0x1300014Eu, // DrudgeDance + 0x43000141u, // DrudgeDanceState + 0x1300014Fu, // HaveASeat + 0x43000145u, // HaveASeatState + 0x13000089u, // HeartyLaugh + 0x13000132u, // Helper + 0x13000092u, // Kneel + 0x430000F7u, // KneelState + 0x1300014Cu, // Knock + 0x13000080u, // Laugh + 0x430000F6u, // LeanState + 0x43000119u, // MeditateState + 0x13000082u, // MimeDrink + 0x13000081u, // MimeEat + 0x130000CBu, // Mock + 0x13000083u, // Nod + 0x13000147u, // NudgeLeft + 0x13000148u, // NudgeRight + 0x13000093u, // Plead + 0x430000F8u, // PleadState + 0x13000084u, // Point + 0x430000F0u, // PointState + 0x1300014Bu, // PointDown + 0x43000140u, // PointDownState + 0x13000149u, // PointLeft + 0x4300013Du, // PointLeftState + 0x1300014Au, // PointRight + 0x4300013Eu, // PointRightState + 0x43000142u, // PossumState + 0x130000CAu, // Pray + 0x430000EBu, // PrayState + 0x43000143u, // ReadState + 0x1300008Au, // Salute + 0x430000F3u, // SaluteState + 0x1300014Du, // ScanHorizon + 0x1300008Bu, // ScratchHead + 0x430000F4u, // ScratchHeadState + 0x13000079u, // ShakeFist + 0x430000EAu, // ShakeFistState + 0x13000085u, // ShakeHead + 0x13000094u, // Shiver + 0x430000EFu, // ShiverState + 0x13000095u, // Shoo + 0x13000086u, // Shrug + 0x4300013Au, // SitState + 0x4300013Cu, // SitBackState + 0x4300013Bu, // SitCrossleggedState + 0x13000096u, // Slouch + 0x430000FAu, // SlouchState + 0x1300008Cu, // SmackHead + 0x43000115u, // SnowAngelState + 0x13000097u, // Spit + 0x13000098u, // Surrender + 0x430000FBu, // SurrenderState + 0x4300013Fu, // TalktotheHandState + 0x1300008Du, // TapFoot + 0x430000F5u, // TapFootState + 0x130000CCu, // Teapot + 0x43000144u, // ThinkerState + 0x13000116u, // WarmHands + 0x13000087u, // Wave + 0x430000F1u, // WaveState + 0x1300008Fu, // WaveLow + 0x1300008Eu, // WaveHigh + 0x1300009Au, // Winded + 0x430000FDu, // WindedState + 0x13000099u, // Woah + 0x430000FCu, // WoahState + 0x13000090u, // YawnStretch + 0x1200009Bu, // YMCA + ]; + + public static int Count => Motions.Length; + + public static bool TryGetMotion(InputAction action, out uint motion) + { + motion = 0u; + if (!RetailActionIdentityTable.TryGetRetailIdentity( + action, + out var identity) + || identity.InputMapId != EmoteInputMap) + { + return false; + } + + uint index = identity.ActionId - FirstEmoteAction; + if (index >= Motions.Length) + return false; + + motion = Motions[index]; + return true; + } +} diff --git a/src/AcDream.App/Input/RetailKeymapFile.cs b/src/AcDream.App/Input/RetailKeymapFile.cs new file mode 100644 index 00000000..66130110 --- /dev/null +++ b/src/AcDream.App/Input/RetailKeymapFile.cs @@ -0,0 +1,601 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Input; + +/// +/// Parser/writer for retail's editable Documents\Asheron's Call\*.keymap +/// PFile text. Only the fourteen user-bindable input maps are replaced when a +/// profile is loaded; acdream-only commands and retail's fixed system/edit/ +/// pointer maps remain owned by the host's base . +/// +public static class RetailKeymapFile +{ + private static readonly Regex BindingLine = new( + "^(?[A-Za-z0-9_]+)\\s*\\[\\s*\"\"\\s*\\[\\s*" + + "(?[0-9]+)\\s+(?[A-Za-z0-9_]+)" + + "(?:\\s+(?[A-Za-z]+))?\\s*\\]" + + "(?:\\s+(?0x[0-9A-Fa-f]+|[0-9]+))?" + + "(?:\\s+(?[A-Za-z]+))?\\s*\\]$", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly (uint Id, string Name)[] GroupOrder = + { + (0x00000004u, "MovementCommands"), + (0x10000007u, "ItemSelectionCommands"), + (0x10000009u, "UICommands"), + (0x1000000Cu, "QuickslotCommands"), + (0x1000000Du, "ToggleChatEntry"), + (0x1000000Au, "ChatCommands"), + (0x10000002u, "Combat"), + (0x10000003u, "MeleeCombat"), + (0x10000004u, "MissileCombat"), + (0x10000005u, "MagicCombat"), + (0x10000006u, "Emotes"), + (0x00000005u, "CameraControls"), + (0x00000006u, "CameraAlternateControls"), + (0x10000008u, "CharacterOptionCommands"), + }; + + private static readonly IReadOnlyDictionary GroupIds = + GroupOrder.ToDictionary(static group => group.Name, static group => group.Id, + StringComparer.OrdinalIgnoreCase); + + // Lazy because the explicit character-option semantic table is declared + // later in this type; field initializers otherwise observe it as null. + private static readonly Lazy> ActionNamesHolder = + new(BuildActionNames); + private static readonly Lazy> ActionsByFileNameHolder = + new(BuildActionsByFileName); + private static IReadOnlyDictionary ActionNames => ActionNamesHolder.Value; + private static IReadOnlyDictionary ActionsByFileName => + ActionsByFileNameHolder.Value; + + public static KeyBindings Parse(string text, KeyBindings baseBindings) + { + ArgumentNullException.ThrowIfNull(text); + ArgumentNullException.ThrowIfNull(baseBindings); + + var result = new KeyBindings(); + foreach (Binding binding in baseBindings.All) + { + if (!RetailActionIdentityTable.ReverseMap.ContainsKey(binding.Action)) + result.Add(binding); + } + + bool foundBindings = false; + bool inBindings = false; + uint? currentGroup = null; + int lineNumber = 0; + foreach (string rawLine in text.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n')) + { + lineNumber++; + string line = rawLine.Trim(); + if (line.Length == 0 || line.StartsWith('#')) + continue; + if (line.Equals("Bindings", StringComparison.OrdinalIgnoreCase)) + { + foundBindings = true; + inBindings = true; + currentGroup = null; + continue; + } + if (!inBindings) + continue; + + // In the PFile grammar every input-map name is a bare identifier on + // the line before its opening bracket. An unknown map clears the + // user-map context so fixed SystemKeys/EditControls rows are ignored. + if (Regex.IsMatch(line, "^[A-Za-z][A-Za-z0-9_]*$", + RegexOptions.CultureInvariant)) + { + currentGroup = GroupIds.TryGetValue(line, out uint groupId) + ? groupId + : null; + continue; + } + if (currentGroup is not uint inputMapId || line is "[" or "]") + continue; + + Match match = BindingLine.Match(line); + if (!match.Success) + throw new FormatException( + $"Malformed retail key binding at line {lineNumber}: {line}"); + + string actionName = match.Groups["action"].Value; + if (!ActionsByFileName.TryGetValue(FileIdentity(inputMapId, actionName), out InputAction action)) + { + // Several fixed/non-user-bindable controls live inside an + // otherwise editable map (UICommands.EscapeKey/LOGOUT in the + // shipped file). They remain in baseBindings just like the + // wholly fixed maps below the user maps. + continue; + } + + string control = match.Groups["control"].Value; + if (!RetailScanCodeMap.TryFromFileControl(control, out uint scan, out uint tokenDevice) + || !uint.TryParse(match.Groups["device"].Value, + NumberStyles.None, CultureInfo.InvariantCulture, out uint device) + || device != tokenDevice + || RetailScanCodeMap.ToSilkKey(scan, device) is not { } key) + { + throw new FormatException( + $"Unsupported retail control '{control}' at line {lineNumber}."); + } + + uint fileModifier = 0u; + if (match.Groups["modifier"].Success) + { + string value = match.Groups["modifier"].Value; + NumberStyles style = value.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? NumberStyles.AllowHexSpecifier + : NumberStyles.None; + string digits = style == NumberStyles.AllowHexSpecifier ? value[2..] : value; + if (!uint.TryParse(digits, style, CultureInfo.InvariantCulture, out fileModifier)) + throw new FormatException($"Invalid modifier at line {lineNumber}."); + } + + var chord = new KeyChord(key, (ModifierMask)(fileModifier & 0x0Fu), (byte)device); + result.Add(new Binding( + chord, + action, + RetailActionIdentityTable.ActivationFor(inputMapId, + RetailActionIdentityTable.ReverseMap[action].ActionId), + RetailActionIdentityTable.ScopeForInputMap(inputMapId))); + } + + if (!foundBindings) + throw new FormatException("The file does not contain a retail Bindings section."); + return result; + } + + public static string Write(KeyBindings bindings) + { + ArgumentNullException.ThrowIfNull(bindings); + var output = new StringBuilder(24_000); + output.AppendLine("#Asheron's Call: Throne of Destiny Keymap File") + .AppendLine("#") + .AppendLine("#Generated by acdream's retail Configure Keyboard screen.") + .AppendLine("#This file is compatible with the Sept-2013 retail PFile keymap grammar.") + .AppendLine("#") + .AppendLine("\"User Defined Keymap\" [ 00000000-0000-0000-0000-000000000000 ]") + .AppendLine() + .AppendLine("Devices") + .AppendLine("[") + .AppendLine(" Keyboard [ GUID_SysKeyboard ]") + .AppendLine(" Mouse [ GUID_SysMouse ]") + .AppendLine(" Virtual [ GUID_Virtual ]") + .AppendLine("]") + .AppendLine() + .AppendLine("MetaKeys") + .AppendLine("[") + .AppendLine(" 1 [ 0 DIK_LSHIFT ]") + .AppendLine(" 2 [ 0 DIK_LCONTROL ]") + .AppendLine(" 2 [ 0 DIK_RCONTROL ]") + .AppendLine(" 3 [ 0 DIK_LMENU ]") + .AppendLine(" 3 [ 0 DIK_RALT ]") + .AppendLine(" 4 [ 0 DIK_LWIN ]") + .AppendLine(" 4 [ 0 DIK_RWIN ]") + .AppendLine(" 5 [ 1 DIMOFS_BUTTON3 ]") + .AppendLine(" 6 [ 1 DIMOFS_BUTTON4 ]") + .AppendLine("]") + .AppendLine() + .AppendLine("Bindings") + .AppendLine("["); + + foreach ((uint inputMapId, string groupName) in GroupOrder) + { + output.Append(" ").AppendLine(groupName).AppendLine(" ["); + foreach (((uint InputMapId, uint ActionId) identity, InputAction action) in + RetailActionIdentityTable.Map + .Where(pair => pair.Key.InputMapId == inputMapId) + .OrderBy(static pair => pair.Key.ActionId)) + { + foreach (Binding binding in bindings.ForAction(action)) + { + if (!RetailScanCodeMap.TryToFileControl(binding.Chord, out string control)) + { + throw new InvalidOperationException( + $"{binding.Chord} cannot be represented by the retail DirectInput keymap."); + } + + output.Append(" ").Append(ActionNames[action]) + .Append(" [ \"\" [ ").Append(binding.Chord.Device) + .Append(' ').Append(control).Append(" ]"); + uint modifier = (uint)binding.Chord.Modifiers & 0x0Fu; + if (modifier != 0u) + output.Append(" 0x").Append(modifier.ToString("X8", CultureInfo.InvariantCulture)); + output.AppendLine(" ]"); + } + } + // Bare Escape is a fixed MasterInputMap control rather than one of + // the user-bindable ActionMap rows (LOGOUT is a normal row and was + // emitted above). Keep it in exported files so the Sept-2013 client + // retains its priority Escape ladder when opening our profile. + if (inputMapId == 0x10000009u) + output.AppendLine(" EscapeKey [ \"\" [ 0 DIK_ESCAPE ] ]"); + output.AppendLine(" ]").AppendLine(); + } + + // Retail's fixed maps are included so a file can also be opened by the + // 2013 client. They are deliberately not imported into the 306-row GUI. + output.Append(FixedRetailMaps); + output.AppendLine("]"); + return output.ToString(); + } + + private static IReadOnlyDictionary BuildActionNames() + { + var names = new Dictionary(); + foreach (InputAction action in RetailActionIdentityTable.ReverseMap.Keys) + names[action] = FileActionName(action); + return names; + } + + private static IReadOnlyDictionary BuildActionsByFileName() + { + var actions = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (((uint InputMapId, uint ActionId) identity, InputAction action) in + RetailActionIdentityTable.Map) + { + actions.Add(FileIdentity(identity.InputMapId, ActionNames[action]), action); + } + return actions; + } + + private static string FileIdentity(uint inputMapId, string actionName) => + $"{inputMapId:X8}:{actionName}"; + + private static string GroupName(uint inputMapId) => + GroupOrder.First(group => group.Id == inputMapId).Name; + + private static string FileActionName(InputAction action) + { + if (CharacterOptionNames.TryGetValue(action, out string? characterOption)) + return characterOption; + if (action == InputAction.SelectionPlaceInInventory) return "SelectionPickUp"; + if (action == InputAction.UseSelected) return "USE"; + string name = action.ToString(); + if (name.StartsWith("CameraAlternate", StringComparison.Ordinal)) + return "Camera" + name["CameraAlternate".Length..]; + if (!name.StartsWith("Emote", StringComparison.Ordinal)) + return name; + string emote = name["Emote".Length..]; + return emote switch + { + "AfkState" => "AFKState", + "AToyotState" => "ATOYOT", + "MimeDrinking" => "MimeDrink", + "MimeEating" => "MimeEat", + "TalkToTheHandState" => "TalktotheHandState", + "YawnAndStretch" => "YawnStretch", + "Ymca" => "YMCA", + _ => emote, + }; + } + + private static readonly IReadOnlyDictionary CharacterOptionNames = + new Dictionary + { + [InputAction.ToggleCharacterOptionAutoRepeatAttack] = "AutoRepeatAttacks", + [InputAction.ToggleCharacterOptionIgnoreAllegianceRequests] = "IgnoreAllegianceRequests", + [InputAction.ToggleCharacterOptionIgnoreFellowshipRequests] = "IgnoreFellowshipRequests", + [InputAction.ToggleCharacterOptionIgnoreTradeRequests] = "IgnoreTradeRequests", + [InputAction.ToggleCharacterOptionPersistentAtDay] = "PersistentAtDay", + [InputAction.ToggleCharacterOptionAllowGive] = "LetPlayersGiveYouItems", + [InputAction.ToggleCharacterOptionViewCombatTarget] = "AutoTrackCombatTargets", + [InputAction.ToggleCharacterOptionShowTooltips] = "DisplayTooltips", + [InputAction.ToggleCharacterOptionUseDeception] = "AttemptToDeceivePlayers", + [InputAction.ToggleCharacterOptionToggleRun] = "RunAsDefaultMovement", + [InputAction.ToggleCharacterOptionStayInChatMode] = "StayInChatModeAfterSend", + [InputAction.ToggleCharacterOptionAdvancedCombatUi] = "AdvancedCombatInterface", + [InputAction.ToggleCharacterOptionAutoTarget] = "AutoTarget", + [InputAction.ToggleCharacterOptionVividTargetingIndicator] = "VividTargetIndicator", + [InputAction.ToggleCharacterOptionFellowshipShareXp] = "ShareFellowshipXP", + [InputAction.ToggleCharacterOptionAcceptLootPermits] = "AcceptCorpseLooting", + [InputAction.ToggleCharacterOptionFellowshipShareLoot] = "ShareFellowshipLoot", + [InputAction.ToggleCharacterOptionFellowshipAutoAcceptRequests] = "AutomaticallyAcceptFellowshipRequests", + [InputAction.ToggleCharacterOptionCoordinatesOnRadar] = "ShowRadarCoordinates", + [InputAction.ToggleCharacterOptionSpellDuration] = "ShowSpellDurations", + [InputAction.ToggleCharacterOptionDisableHouseRestrictionEffects] = "DisableHouseEffect", + [InputAction.ToggleCharacterOptionDragItemOnPlayerOpensSecureTrade] = "DragItemOnPlayerOpensSecureTrade", + [InputAction.ToggleCharacterOptionDisplayAllegianceLogonNotifications] = "DisplayAllegianceLogonNotifications", + [InputAction.ToggleCharacterOptionUseChargeAttack] = "UseChargeAttack", + [InputAction.ToggleCharacterOptionUseCraftSuccessDialog] = "ToggleCraftingChanceOfSuccessDialog", + [InputAction.ToggleCharacterOptionListenToAllegianceChat] = "AllegianceChat", + [InputAction.ToggleCharacterOptionDisplayDateOfBirth] = "DisplayDateOfBirth", + [InputAction.ToggleCharacterOptionDisplayAge] = "DisplayAge", + [InputAction.ToggleCharacterOptionDisplayChessRank] = "DisplayChessRank", + [InputAction.ToggleCharacterOptionDisplayFishingSkill] = "Fishing", + [InputAction.ToggleCharacterOptionDisplayNumberDeaths] = "DisplayNumberDeaths", + [InputAction.ToggleCharacterOptionDisplayTimeStamps] = "DisplayTimeStamps", + [InputAction.ToggleCharacterOptionSalvageMultiple] = "SalvageMultiple", + [InputAction.ToggleCharacterOptionListenToGeneralChat] = "GeneralChat", + [InputAction.ToggleCharacterOptionListenToTradeChat] = "TradeChat", + [InputAction.ToggleCharacterOptionListenToLfgChat] = "LFGChat", + [InputAction.ToggleCharacterOptionListenToRoleplayChat] = "RoleplayChat", + [InputAction.ToggleCharacterOptionDisplayNumberCharacterTitles] = "DisplayNumberCharacterTitles", + [InputAction.ToggleCharacterOptionMainPackPreferred] = "MainPackPreferred", + [InputAction.ToggleCharacterOptionLeadMissileTargets] = "LeadMissileTargets", + [InputAction.ToggleCharacterOptionUseFastMissiles] = "UseFastMissiles", + [InputAction.ToggleCharacterOptionFilterLanguage] = "FilterLanguage", + [InputAction.ToggleCharacterOptionConfirmVolatileRareUse] = "ConfirmVolatileRareUse", + [InputAction.ToggleCharacterOptionListenToSocietyChat] = "SocietyChat", + [InputAction.ToggleCharacterOptionShowHelm] = "ShowHelm", + [InputAction.ToggleCharacterOptionDisableDistanceFog] = "DisableDistanceFog", + [InputAction.ToggleCharacterOptionShowCloak] = "ShowCloak", + [InputAction.ToggleCharacterOptionSideBySideVitals] = "SideBySideVitals", + }; + + private const string FixedRetailMaps = """ + TargetedUsage + [ + SelectLeft [ "" [ 1 DIMOFS_BUTTON0 ] ] + SelectRight [ "" [ 1 DIMOFS_BUTTON1 ] ] + ] + + SystemKeys + [ + AltEnter [ "" [ 0 DIK_RETURN ] 0x00000004 ] + AltTab [ "" [ 0 DIK_TAB ] 0x00000004 ] + AltF4 [ "" [ 0 DIK_F4 ] 0x00000004 ] + CtrlShiftEsc [ "" [ 0 DIK_ESCAPE ] 0x00000003 ] + ] + + MouseCommands + [ + PointerX [ "" [ 1 DIMOFS_X ] 0x00000000 Analog ] + PointerY [ "" [ 1 DIMOFS_Y ] 0x00000000 Analog ] + SelectLeft [ "" [ 1 DIMOFS_BUTTON0 ] ] + SelectRight [ "" [ 1 DIMOFS_BUTTON1 ] ] + SelectMid [ "" [ 1 DIMOFS_BUTTON2 ] ] + SelectDblLeft [ "" [ 1 DIMOFS_BUTTON0 ] 0x00000000 MouseDblClick ] + SelectDblRight [ "" [ 1 DIMOFS_BUTTON1 ] 0x00000000 MouseDblClick ] + SelectDblMid [ "" [ 1 DIMOFS_BUTTON2 ] 0x00000000 MouseDblClick ] + ] + + ScrollableControls + [ + ScrollUp [ "" [ 1 DIMOFS_Z AxisPositive ] ] + ScrollDown [ "" [ 1 DIMOFS_Z AxisNegative ] ] + ScrollUp [ "" [ 0 DIK_UPARROW ] 0x00000002 ] + ScrollDown [ "" [ 0 DIK_DOWNARROW ] 0x00000002 ] + ] + + EditControls + [ + CursorCharLeft [ "" [ 0 DIK_LEFT ] ] + CursorCharRight [ "" [ 0 DIK_RIGHTARROW ] ] + CursorPreviousLine [ "" [ 0 DIK_UPARROW ] ] + CursorNextLine [ "" [ 0 DIK_DOWNARROW ] ] + CursorPreviousPage [ "" [ 0 DIK_PGUP ] ] + CursorNextPage [ "" [ 0 DIK_PGDN ] ] + CursorWordLeft [ "" [ 0 DIK_LEFT ] 0x00000002 ] + CursorWordRight [ "" [ 0 DIK_RIGHTARROW ] 0x00000002 ] + CursorStartOfLine [ "" [ 0 DIK_HOME ] ] + CursorStartOfDocument [ "" [ 0 DIK_HOME ] 0x00000002 ] + CursorEndOfLine [ "" [ 0 DIK_END ] ] + CursorEndOfDocument [ "" [ 0 DIK_END ] 0x00000002 ] + EscapeKey [ "" [ 0 DIK_ESCAPE ] ] + AcceptInput [ "" [ 0 DIK_RETURN ] ] + DeleteKey [ "" [ 0 DIK_DELETE ] ] + BackspaceKey [ "" [ 0 DIK_BACK ] ] + ] + + CopyAndPasteControls + [ + CopyText [ "" [ 0 DIK_C ] 0x00000002 ] + CopyText [ "" [ 0 DIK_INSERT ] 0x00000002 ] + CutText [ "" [ 0 DIK_X ] 0x00000002 ] + CutText [ "" [ 0 DIK_DELETE ] 0x00000001 ] + PasteText [ "" [ 0 DIK_V ] 0x00000002 ] + PasteText [ "" [ 0 DIK_INSERT ] 0x00000001 ] + ] + + DialogBoxes + [ + EscapeKey [ "" [ 0 DIK_ESCAPE ] ] + AcceptInput [ "" [ 0 DIK_RETURN ] ] + ] + + """; +} + +public enum RetailKeymapSaveStatus +{ + Saved, + Exists, + ReadOnly, + InvalidName, + Failed, +} + +public readonly record struct RetailKeymapSaveResult( + RetailKeymapSaveStatus Status, + string FileName, + string? Error = null); + +/// +/// Owns retail's active-profile preference and *.keymap directory. +/// The profile selector lives beside acdream's portable JSON mirror; profile +/// files live in retail's Documents/Asheron's Call folder. +/// +public sealed class RetailKeymapProfileStore +{ + public const string DefaultFileName = "acdream.keymap"; + + private readonly string _jsonPath; + private readonly string _directory; + private readonly string _selectorPath; + + public RetailKeymapProfileStore(string jsonPath, string? keymapDirectory = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(jsonPath); + _jsonPath = Path.GetFullPath(jsonPath); + string configDirectory = Path.GetDirectoryName(_jsonPath) + ?? Directory.GetCurrentDirectory(); + _selectorPath = Path.Combine(configDirectory, "active-keymap.txt"); + _directory = keymapDirectory ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), + "Asheron's Call"); + } + + public string DirectoryPath => _directory; + + public string CurrentFileName + { + get + { + try + { + if (File.Exists(_selectorPath)) + { + string selected = NormalizeFileName(File.ReadAllText(_selectorPath)); + if (selected.Length != 0) return selected; + } + } + catch (Exception failure) + { + Console.WriteLine($"keymap: active-profile preference could not be read: {failure.Message}"); + } + return DefaultFileName; + } + } + + public IReadOnlyList ListFiles() + { + try + { + if (!Directory.Exists(_directory)) return Array.Empty(); + return Directory.EnumerateFiles(_directory, "*.keymap", SearchOption.TopDirectoryOnly) + .Select(Path.GetFileName) + .Where(static name => !string.IsNullOrEmpty(name)) + .Cast() + .OrderBy(static name => name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + catch (Exception failure) + { + Console.WriteLine($"keymap: profile list failed: {failure.Message}"); + return Array.Empty(); + } + } + + public bool TryLoad( + string fileName, + KeyBindings baseBindings, + out KeyBindings bindings, + out string? error) + { + bindings = baseBindings; + error = null; + string normalized = NormalizeFileName(fileName); + if (normalized.Length == 0) + { + error = "The keymap filename is invalid."; + return false; + } + + try + { + string text = File.ReadAllText(Path.Combine(_directory, normalized)); + bindings = RetailKeymapFile.Parse(text, baseBindings); + WriteSelector(normalized); + return true; + } + catch (Exception failure) + { + error = failure.Message; + return false; + } + } + + public RetailKeymapSaveResult Save( + string fileName, + KeyBindings bindings, + bool overwrite) + { + string normalized = NormalizeFileName(fileName); + if (normalized.Length == 0) + return new(RetailKeymapSaveStatus.InvalidName, string.Empty); + + string path = Path.Combine(_directory, normalized); + try + { + if (File.Exists(path)) + { + if (!overwrite) + return new(RetailKeymapSaveStatus.Exists, normalized); + if ((File.GetAttributes(path) & FileAttributes.ReadOnly) != 0) + return new(RetailKeymapSaveStatus.ReadOnly, normalized); + } + + Directory.CreateDirectory(_directory); + AtomicWrite(path, RetailKeymapFile.Write(bindings)); + WriteSelector(normalized); + return new(RetailKeymapSaveStatus.Saved, normalized); + } + catch (UnauthorizedAccessException failure) + { + return new(RetailKeymapSaveStatus.ReadOnly, normalized, failure.Message); + } + catch (Exception failure) + { + return new(RetailKeymapSaveStatus.Failed, normalized, failure.Message); + } + } + + public RetailKeymapSaveResult SaveActive(KeyBindings bindings) => + Save(CurrentFileName, bindings, overwrite: true); + + public static KeyBindings LoadActiveOrJson( + string jsonPath, + out string profileName) + { + KeyBindings fallback = KeyBindings.LoadOrDefault(jsonPath); + var store = new RetailKeymapProfileStore(jsonPath); + profileName = store.CurrentFileName; + string profilePath = Path.Combine(store.DirectoryPath, profileName); + if (!File.Exists(profilePath)) return fallback; + if (store.TryLoad(profileName, fallback, out KeyBindings loaded, out string? error)) + return loaded; + Console.WriteLine($"keymap: '{profileName}' could not be loaded; using JSON/defaults: {error}"); + return fallback; + } + + public static string NormalizeFileName(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + string trimmed = value.Trim(); + if (!string.Equals(trimmed, Path.GetFileName(trimmed), StringComparison.Ordinal)) + return string.Empty; + if (trimmed.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + return string.Empty; + return trimmed.EndsWith(".keymap", StringComparison.OrdinalIgnoreCase) + ? trimmed + : trimmed + ".keymap"; + } + + private void WriteSelector(string fileName) + { + string? directory = Path.GetDirectoryName(_selectorPath); + if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); + AtomicWrite(_selectorPath, fileName + Environment.NewLine); + } + + private static void AtomicWrite(string path, string content) + { + string temp = path + ".tmp-" + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + try + { + File.WriteAllText(temp, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + File.Move(temp, path, overwrite: true); + } + finally + { + if (File.Exists(temp)) File.Delete(temp); + } + } +} diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs index 35f40b98..d70ace45 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionController.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -24,6 +24,8 @@ internal sealed class SelectionInteractionController private readonly IPlayerInteractionMovementSink _movement; private readonly PlayerApproachCompletionState _approachCompletions; private readonly Action? _toast; + private readonly Func? _splitStack; + private readonly Func> _fellowshipMembers; public SelectionInteractionController( SelectionState selection, @@ -32,7 +34,9 @@ internal sealed class SelectionInteractionController IRuntimeInteractionTransport transport, IPlayerInteractionMovementSink movement, Action? toast = null, - PlayerApproachCompletionState? approachCompletions = null) + PlayerApproachCompletionState? approachCompletions = null, + Func? splitStack = null, + Func>? fellowshipMembers = null) { _selection = selection ?? throw new ArgumentNullException(nameof(selection)); _query = query ?? throw new ArgumentNullException(nameof(query)); @@ -43,14 +47,96 @@ internal sealed class SelectionInteractionController _toast = toast; _approachCompletions = approachCompletions ?? new PlayerApproachCompletionState(); + _splitStack = splitStack; + _fellowshipMembers = fellowshipMembers ?? (() => Array.Empty()); } public bool HandleInputAction(InputAction action) { switch (action) { + case InputAction.SelectionSelf: + SelectSelf(); + return true; + case InputAction.SelectionPlaceInInventory: + PlaceSelectionInBackpack(mainPack: false); + return true; + case InputAction.SelectionPlaceInMainPack: + PlaceSelectionInBackpack(mainPack: true); + return true; + case InputAction.SelectionSplitStack: + if (_selection.SelectedObjectId is { } stack) + _splitStack?.Invoke(stack); + return true; + case InputAction.SelectionClosestCompassItem: + SelectRetailTarget(RetailSelectionKind.CompassItem, RetailSelectionDirection.Closest); + return true; + case InputAction.SelectionPreviousCompassItem: + SelectRetailTarget(RetailSelectionKind.CompassItem, RetailSelectionDirection.Previous); + return true; + case InputAction.SelectionNextCompassItem: + SelectRetailTarget(RetailSelectionKind.CompassItem, RetailSelectionDirection.Next); + return true; + case InputAction.SelectionClosestItem: + SelectRetailTarget( + RetailSelectionKind.Item, + RetailSelectionDirection.Closest, + excludeOwnedByPlayer: true); + return true; + case InputAction.SelectionPreviousItem: + SelectRetailTarget(RetailSelectionKind.Item, RetailSelectionDirection.Previous); + return true; + case InputAction.SelectionNextItem: + SelectRetailTarget(RetailSelectionKind.Item, RetailSelectionDirection.Next); + return true; case InputAction.SelectionClosestMonster: - SelectClosestCombatTarget(showToast: true); + SelectRetailTarget( + RetailSelectionKind.Monster, + RetailSelectionDirection.Closest, + showToast: true); + return true; + case InputAction.SelectionPreviousMonster: + SelectRetailTarget(RetailSelectionKind.Monster, RetailSelectionDirection.Previous); + return true; + case InputAction.SelectionNextMonster: + SelectRetailTarget(RetailSelectionKind.Monster, RetailSelectionDirection.Next); + return true; + case InputAction.SelectionLastAttacker: + if (_query.FindLastAttacker() is { } attacker) + _selection.Select(attacker, SelectionChangeSource.Keyboard); + return true; + case InputAction.SelectionClosestPlayer: + SelectRetailTarget(RetailSelectionKind.Player, RetailSelectionDirection.Closest); + return true; + case InputAction.SelectionPreviousPlayer: + SelectRetailTarget(RetailSelectionKind.Player, RetailSelectionDirection.Previous); + return true; + case InputAction.SelectionNextPlayer: + SelectRetailTarget(RetailSelectionKind.Player, RetailSelectionDirection.Next); + return true; + case InputAction.SelectionPreviousFellow: + SelectFellow(previous: true); + return true; + case InputAction.SelectionNextFellow: + SelectFellow(previous: false); + return true; + case InputAction.SelectionClosestUnopenedCorpse: + SelectRetailTarget(RetailSelectionKind.UnopenedCorpse, RetailSelectionDirection.Closest); + return true; + case InputAction.SelectionNextUnopenedCorpse: + SelectRetailTarget(RetailSelectionKind.UnopenedCorpse, RetailSelectionDirection.Next); + return true; + case InputAction.SelectionUseClosestUnopenedCorpse: + SelectAndUseCorpse(RetailSelectionDirection.Closest); + return true; + case InputAction.SelectionUseNextUnopenedCorpse: + SelectAndUseCorpse(RetailSelectionDirection.Next); + return true; + case InputAction.SelectionGiveToTarget: + GiveSelectionToPreviousTarget(); + return true; + case InputAction.SelectionDrop: + DropSelection(); return true; case InputAction.SelectionPreviousSelection: _selection.SelectPrevious(); @@ -87,11 +173,109 @@ internal sealed class SelectionInteractionController case InputAction.EscapeKey when _items.IsAnyTargetModeActive: _items.CancelTargetMode(); return true; + case InputAction.EscapeKey when _selection.SelectedObjectId is not null: + // ClientUISystem::OnAction @0x00564C8E: Escape willingly + // loses the current target before it reaches the Gameplay + // Options fallback at 0x00564CBF. + _selection.Clear(SelectionChangeSource.Keyboard); + return true; default: return false; } } + private void SelectSelf() + { + uint playerGuid = _query.PlayerGuid; + if (playerGuid == 0u) + return; + if (_items.OfferPrimaryClick(playerGuid) is not ItemPrimaryClickResult.NotActive) + return; + _selection.Select(playerGuid, SelectionChangeSource.Keyboard); + } + + private void PlaceSelectionInBackpack(bool mainPack) + { + if (_selection.SelectedObjectId is { } selected) + _items.PlaceWorldItemInBackpack(selected, mainPack); + } + + private void SelectRetailTarget( + RetailSelectionKind kind, + RetailSelectionDirection direction, + bool excludeOwnedByPlayer = false, + bool showToast = false) + { + uint? anchor = _selection.SelectedObjectId ?? _selection.PreviousObjectId; + uint? target = _query.FindSelectionTarget( + kind, + direction, + anchor, + excludeOwnedByPlayer); + if (target is { } guid) + { + _selection.Select(guid, SelectionChangeSource.Keyboard); + if (showToast) + _toast?.Invoke(_query.Describe(guid)); + } + } + + private void SelectAndUseCorpse(RetailSelectionDirection direction) + { + SelectRetailTarget(RetailSelectionKind.UnopenedCorpse, direction); + if (_selection.SelectedObjectId is { } corpse) + EnqueueIdentityBound( + RuntimeQueuedInteractionKind.Use, + corpse, + requireLiveEntity: false); + } + + private void SelectFellow(bool previous) + { + uint[] fellows = _fellowshipMembers() + .Where(static guid => guid != 0u) + .Distinct() + .ToArray(); + if (fellows.Length == 0) + return; + + int current = _selection.SelectedObjectId is { } selected + ? Array.IndexOf(fellows, selected) + : -1; + int next = previous + ? (current > 0 ? current - 1 : fellows.Length - 1) + : (current >= 0 && current + 1 < fellows.Length ? current + 1 : 0); + _selection.Select(fellows[next], SelectionChangeSource.Keyboard); + } + + private void GiveSelectionToPreviousTarget() + { + if (_selection.SelectedObjectId is not { } selected + || _selection.PreviousObjectId is not { } target + || selected == target + || !_query.IsCreature(target)) + { + _toast?.Invoke( + "You must select a creature or a character to give that to.\n"); + return; + } + + if (_items.PlaceSelectedIn3D(selected, target)) + _selection.Select(target, SelectionChangeSource.Keyboard); + } + + private void DropSelection() + { + if (_selection.SelectedObjectId is not { } selected) + return; + if (!_items.IsOwnedByPlayer(selected)) + { + _toast?.Invoke("You must pick that up first"); + return; + } + _items.PlaceSelectedIn3D(selected, targetGuid: 0u); + } + public uint? PickAtCursor(bool includeSelf) => _query.PickAtCursor(includeSelf); diff --git a/src/AcDream.App/Interaction/WorldSelectionQuery.cs b/src/AcDream.App/Interaction/WorldSelectionQuery.cs index 883504bd..485ff89f 100644 --- a/src/AcDream.App/Interaction/WorldSelectionQuery.cs +++ b/src/AcDream.App/Interaction/WorldSelectionQuery.cs @@ -6,7 +6,9 @@ using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Physics; +using AcDream.Core.Properties; using AcDream.Core.Selection; +using AcDream.Core.Ui; using AcDream.Core.World; namespace AcDream.App.Interaction; @@ -25,6 +27,22 @@ internal readonly record struct WorldInteractionTarget( internal readonly record struct ClosestCombatTarget(uint ServerGuid, float DistanceSquared); +internal enum RetailSelectionKind +{ + Item, + CompassItem, + Monster, + Player, + UnopenedCorpse, +} + +internal enum RetailSelectionDirection +{ + Closest, + Previous, + Next, +} + internal readonly record struct InteractionApproach( WorldInteractionTarget Target, PlayerInteractionPose Player, @@ -36,6 +54,7 @@ internal readonly record struct InteractionApproach( internal interface IWorldSelectionQuery { + uint PlayerGuid => 0u; uint? PickAtCursor(bool includeSelf); uint? PickAt(float mouseX, float mouseY, bool includeSelf); void BeginLightingPulse(uint serverGuid); @@ -46,6 +65,16 @@ internal interface IWorldSelectionQuery bool IsHostileMonster(uint serverGuid); bool IsAttackableTarget(uint serverGuid); ClosestCombatTarget? FindClosestHostileMonster(); + uint? FindSelectionTarget( + RetailSelectionKind kind, + RetailSelectionDirection direction, + uint? anchor, + bool excludeOwnedByPlayer = false) => + kind == RetailSelectionKind.Monster + && direction == RetailSelectionDirection.Closest + ? FindClosestHostileMonster()?.ServerGuid + : null; + uint? FindLastAttacker() => null; bool IsUseable(uint serverGuid); bool IsPickupable(uint serverGuid); bool IsWieldedByPlayer(uint serverGuid); @@ -111,6 +140,9 @@ internal sealed class WorldSelectionQuery private readonly Func _setupCylinder; private readonly Func _selectionSphere; private readonly Func _childRootPose; + private readonly Func _hasOpenedCorpse; + private readonly Func _combatMode; + private readonly Func _isFellow; public WorldSelectionQuery( LiveEntityRuntime liveEntities, @@ -122,7 +154,10 @@ internal sealed class WorldSelectionQuery Func playerPose, Func setupCylinder, Func selectionSphere, - Func childRootPose) + Func childRootPose, + Func? hasOpenedCorpse = null, + Func? combatMode = null, + Func? isFellow = null) { _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _objects = objects ?? throw new ArgumentNullException(nameof(objects)); @@ -134,8 +169,13 @@ internal sealed class WorldSelectionQuery _setupCylinder = setupCylinder ?? throw new ArgumentNullException(nameof(setupCylinder)); _selectionSphere = selectionSphere ?? throw new ArgumentNullException(nameof(selectionSphere)); _childRootPose = childRootPose ?? throw new ArgumentNullException(nameof(childRootPose)); + _hasOpenedCorpse = hasOpenedCorpse ?? (_ => false); + _combatMode = combatMode ?? (() => CombatMode.NonCombat); + _isFellow = isFellow ?? (_ => false); } + public uint PlayerGuid => _playerGuid(); + public uint? PickAtCursor(bool includeSelf) { Vector2 cursor = _cursor(); @@ -293,6 +333,183 @@ internal sealed class WorldSelectionQuery return best; } + /// + /// Port of retail CPlayerSystem::SelectNext @ 0x0055F9A0. The + /// ordering scalar is the retail player-space horizontal distance plus + /// 1.2 * abs(z); the object id breaks exact-distance ties through + /// CPlayerSystem::Farther @ 0x0055D830. Previous/next wrap exactly + /// as the paired calls in CPlayerSystem::OnAction @ 0x00561890. + /// + public uint? FindSelectionTarget( + RetailSelectionKind kind, + RetailSelectionDirection direction, + uint? anchor, + bool excludeOwnedByPlayer = false) + { + uint playerGuid = _playerGuid(); + if (!_liveEntities.TryGetWorldEntity(playerGuid, out WorldEntity player)) + return null; + + float radarRadius = IsOutdoorCell(player.VisibilityCellId) + ? RetailRadar.OutdoorRangeMeters + : RetailRadar.IndoorRangeMeters; + var candidates = new List<(uint Guid, float Order)>(); + foreach (LiveEntityRecord record in _liveEntities.VisibleRecords) + { + uint guid = record.ServerGuid; + if (guid == 0u + || guid == playerGuid + || record.WorldEntity is not { } entity + || _objects.Get(guid) is not { } obj + || (excludeOwnedByPlayer + && _objects.IsOwnedByObject(guid, playerGuid))) + { + continue; + } + + float order = SelectionOrder(player, entity); + if (order > radarRadius + || !MatchesSelectionKind(kind, guid, obj, record.FinalPhysicsState)) + continue; + candidates.Add((guid, order)); + } + + if (candidates.Count == 0) + return null; + candidates.Sort(static (left, right) => + { + int distance = left.Order.CompareTo(right.Order); + return distance != 0 ? distance : left.Guid.CompareTo(right.Guid); + }); + + if (direction == RetailSelectionDirection.Closest) + return candidates[0].Guid; + + (float Order, uint Guid)? anchorKey = null; + if (anchor is { } anchorGuid + && _liveEntities.TryGetWorldEntity(anchorGuid, out WorldEntity anchorEntity)) + { + anchorKey = (SelectionOrder(player, anchorEntity), anchorGuid); + } + + if (anchorKey is null) + { + return direction == RetailSelectionDirection.Previous + ? candidates[^1].Guid + : candidates[0].Guid; + } + + if (direction == RetailSelectionDirection.Next) + { + foreach ((uint guid, float order) in candidates) + { + if (CompareSelectionKey(order, guid, anchorKey.Value.Order, anchorKey.Value.Guid) > 0) + return guid; + } + return candidates[0].Guid; + } + + for (int i = candidates.Count - 1; i >= 0; i--) + { + (uint guid, float order) = candidates[i]; + if (CompareSelectionKey(order, guid, anchorKey.Value.Order, anchorKey.Value.Guid) < 0) + return guid; + } + return candidates[^1].Guid; + } + + public uint? FindLastAttacker() + { + uint playerGuid = _playerGuid(); + uint attacker = 0u; + if (_objects.Get(playerGuid) is not { } playerObject + || !playerObject.Properties.InstanceIds.TryGetValue( + (uint)PropertyInstanceId.CurrentAttacker, + out attacker) + || attacker == 0u + || !_liveEntities.TryGetWorldEntity(playerGuid, out WorldEntity player) + || !_liveEntities.TryGetWorldEntity(attacker, out WorldEntity target)) + { + return null; + } + + float radarRadius = IsOutdoorCell(player.VisibilityCellId) + ? RetailRadar.OutdoorRangeMeters + : RetailRadar.IndoorRangeMeters; + return SelectionOrder(player, target) <= radarRadius ? attacker : null; + } + + private bool MatchesSelectionKind( + RetailSelectionKind kind, + uint guid, + ClientObject obj, + PhysicsStateFlags physicsState) + { + bool showableOnRadar = obj.RadarBehavior is { } behavior + && RetailRadar.IsShowable((RadarBehavior)behavior, hasPhysicsObject: true); + PublicWeenieFlags flags = (PublicWeenieFlags)(obj.PublicWeenieBitfield ?? 0u); + bool isFellow = _isFellow(guid); + bool isCombatCompass = _combatMode() is CombatMode.Melee or CombatMode.Missile; + bool isSpecialCompassObject = (flags + & (PublicWeenieFlags.Lifestone + | PublicWeenieFlags.Portal + | PublicWeenieFlags.Bindstone)) != 0; + + // The common tail of CPlayerSystem::SelectNext rejects every object + // currently inside a container, every cloaked physics object, and a + // PWD carrying the reserved sign bit, independent of selection kind. + if (obj.ContainerId != 0u + || (physicsState & PhysicsStateFlags.Cloaked) != 0 + || (((uint)flags & 0x8000_0000u) != 0)) + return false; + + return kind switch + { + RetailSelectionKind.Item => + obj.RadarBehavior is null or 0 + || isSpecialCompassObject, + RetailSelectionKind.CompassItem => + (isSpecialCompassObject || showableOnRadar) + && (!isCombatCompass + || (IsAttackableTarget(guid) + && !isFellow + && (flags & PublicWeenieFlags.Vendor) == 0 + && (physicsState & PhysicsStateFlags.ReportAsEnvironment) == 0)), + RetailSelectionKind.Monster => + showableOnRadar + && IsAttackableTarget(guid) + && !isFellow + && (flags & PublicWeenieFlags.Vendor) == 0, + RetailSelectionKind.Player => + showableOnRadar && (flags & PublicWeenieFlags.Player) != 0, + RetailSelectionKind.UnopenedCorpse => + (flags & PublicWeenieFlags.Corpse) != 0 + && !_hasOpenedCorpse(guid), + _ => false, + }; + } + + private static float SelectionOrder(WorldEntity player, WorldEntity target) + { + Vector3 delta = target.Position - player.Position; + Vector3 local = Vector3.Transform(delta, Quaternion.Inverse(player.Rotation)); + return MathF.Sqrt(local.X * local.X + local.Y * local.Y) + + MathF.Abs(local.Z) * 1.2f; + } + + private static int CompareSelectionKey( + float leftOrder, + uint leftGuid, + float rightOrder, + uint rightGuid) + { + int order = leftOrder.CompareTo(rightOrder); + return order != 0 ? order : leftGuid.CompareTo(rightGuid); + } + + private static bool IsOutdoorCell(uint? cellId) + => cellId is null || (cellId.Value & 0xFFFFu) < 0x100u; + /// /// #298 follow-up: retail ClientCombatSystem::UpdateTargetTracking /// @ 0x0056A950 (pc:375691-375696) gates CameraSet::TrackTarget diff --git a/src/AcDream.App/Net/DatChatPoseCatalog.cs b/src/AcDream.App/Net/DatChatPoseCatalog.cs new file mode 100644 index 00000000..39649058 --- /dev/null +++ b/src/AcDream.App/Net/DatChatPoseCatalog.cs @@ -0,0 +1,81 @@ +using AcDream.Runtime.Chat; +using AcDream.Content; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatMotionCommand = DatReaderWriter.Enums.MotionCommand; + +namespace AcDream.App.Net; + +/// +/// Immutable projection of retail's portal-DAT ChatPoseTable (0x0E000007). +/// Command lookup is case-insensitive, matching +/// ChatPoseTable::InqChatPoseCommand @ 0x00570AD0. +/// +internal sealed class DatChatPoseCatalog +{ + private const uint ChatPoseTableId = 0x0E000007u; + private readonly IReadOnlyDictionary _poses; + + private DatChatPoseCatalog( + IReadOnlyDictionary poses) => + _poses = poses; + + public static DatChatPoseCatalog Load(IDatReaderWriter dats, object datLock) + { + ArgumentNullException.ThrowIfNull(dats); + ArgumentNullException.ThrowIfNull(datLock); + lock (datLock) + { + ChatPoseTable? table = dats.Get(ChatPoseTableId); + if (table is null) + return new DatChatPoseCatalog( + new Dictionary( + StringComparer.OrdinalIgnoreCase)); + + var emotes = new Dictionary( + StringComparer.OrdinalIgnoreCase); + foreach (var pair in table.ChatEmotes) + { + emotes[pair.Key.Value] = ( + pair.Value.MyEmote.Value, + pair.Value.OtherEmote.Value); + } + + var poses = new Dictionary( + StringComparer.OrdinalIgnoreCase); + foreach (var pair in table.ChatPoses) + { + string command = pair.Key.Value; + string motionName = pair.Value.Value; + if (string.IsNullOrEmpty(command) + || !Enum.TryParse( + motionName, + ignoreCase: true, + out DatMotionCommand motion)) + { + continue; + } + emotes.TryGetValue(motionName, out var text); + poses[command] = new RetailChatPose( + (uint)motion, + text.Self ?? string.Empty, + text.Others ?? string.Empty); + } + return new DatChatPoseCatalog(poses); + } + } + + public RetailChatPose? Resolve(string command, bool male) + { + if (!_poses.TryGetValue(command, out RetailChatPose pose)) + return null; + string possessive = male ? "his" : "her"; + return pose with + { + OthersText = pose.OthersText.Replace( + "%p", + possessive, + StringComparison.Ordinal), + }; + } +} diff --git a/src/AcDream.App/Net/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs index c2969e76..2a72e03a 100644 --- a/src/AcDream.App/Net/LiveSessionCommandRouter.cs +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -74,7 +74,10 @@ internal sealed record LiveSessionCommandBindings( Action SendAllegianceKick, Action SendAllegianceInfoRequest, Action SendAllegianceUpdateRequest, - Action? Log = null); + Action? Log = null, + Func? ResolvePose = null, + Action? ExecuteMotion = null, + Action? SendSoulEmote = null); internal readonly record struct AddShortcutRuntimeCmd(ShortcutEntry Entry); internal readonly record struct RemoveShortcutRuntimeCmd(uint Index); @@ -185,7 +188,10 @@ internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting bindings.SendTell, bindings.SendChannel, bindings.SendTurbineChat, - bindings.Log)); + bindings.Log, + bindings.ResolvePose, + bindings.ExecuteMotion, + bindings.SendSoulEmote)); // Campaign CH slice CH4 (2026-08-09): the 22 unregistered // ChannelSystem::GetChannelID fallback tags — bypasses // ChatChannelKind/ChannelResolver entirely and sends the raw diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 9aa33819..6ea3a7b6 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -67,6 +67,7 @@ internal sealed record LiveSessionInteractionRuntime( internal sealed record LiveSessionWorldRuntime( IDatReaderWriter Dats, + object DatLock, // Logout-audio round (2026-08-17): null only when audio is disabled // (ACDREAM_NO_AUDIO / init failure) — the reset step and entered-world // resume both no-op then. @@ -114,6 +115,7 @@ internal sealed class LiveSessionRuntimeFactory private readonly IReadOnlyList _loginCommands; private readonly TimeSpan _loginCommandDelay; private readonly TimeProvider _timeProvider; + private readonly DatChatPoseCatalog _chatPoses; /// /// Where a bare @log filename lands. See @@ -162,6 +164,7 @@ internal sealed class LiveSessionRuntimeFactory _loginCommands = loginCommands is null ? [] : [.. loginCommands]; _loginCommandDelay = TimeSpan.FromMilliseconds(loginCommandDelayMs); _timeProvider = timeProvider ?? TimeProvider.System; + _chatPoses = DatChatPoseCatalog.Load(_world.Dats, _world.DatLock); // C3c-F1: stat recomputes route through the Runtime movement owner's // typed application seam; App keeps zero direct controller mutations. _movementStats = new LiveMovementStatsApplier( @@ -217,6 +220,15 @@ internal sealed class LiveSessionRuntimeFactory RestoreLayout: () => { _ui.RetailUi?.RestoreLayout(); + // The retained inventory controller exists before the + // character object graph is complete. Rebuild its open + // container once EnteredWorld makes that graph + // authoritative, otherwise the already-open main pack can + // keep the empty construction-time cells until the user + // switches packs. Redress the private doll at the same + // character-complete edge. + _ui.RetailUi?.InventoryPanelController?.Populate(); + _ui.Paperdoll?.MarkDirty(); // MUST-FIX 3 re-fix (FA4 re-review REOPEN): re-declare a // still-open Fellowship page's 0x00A6 now we are in world — // RestoreLayout is the post-world UI-restore moment, and @@ -786,7 +798,14 @@ internal sealed class LiveSessionRuntimeFactory SendAllegianceKick: session.SendAllegianceKick, SendAllegianceInfoRequest: session.SendAllegianceInfoRequest, SendAllegianceUpdateRequest: session.SendAllegianceUpdateRequest, - Log: _log); + Log: _log, + ResolvePose: command => _chatPoses.Resolve( + command, + male: _domain.EntityObjects.Objects + .Get(_player.Identity.ServerGuid)? + .Properties.GetInt(0x71u) == 1), + ExecuteMotion: motion => _player.Controller.ExecuteMotion(motion), + SendSoulEmote: session.SendSoulEmote); } private static double ClientTimerNow() => diff --git a/src/AcDream.App/Rendering/CameraFrameController.cs b/src/AcDream.App/Rendering/CameraFrameController.cs index 949942d7..a0cb3202 100644 --- a/src/AcDream.App/Rendering/CameraFrameController.cs +++ b/src/AcDream.App/Rendering/CameraFrameController.cs @@ -85,6 +85,28 @@ internal sealed class CameraFrameController : ICameraFramePhase retail.AdjustPitch(+adjustment * 0.02f); if (input.Lower) retail.AdjustPitch(-adjustment * 0.02f); + if (input.RotateLeft) + retail.YawOffset += adjustment * 0.02f; + if (input.RotateRight) + retail.YawOffset -= adjustment * 0.02f; + } + else + { + ChaseCameraAdjustmentInput input = _input.CaptureChaseAdjustment(); + float adjustment = CameraDiagnostics.CameraAdjustmentSpeed + * timing.SimulationDeltaSecondsSingle; + if (input.ZoomIn) + legacy.AdjustDistance(-adjustment); + if (input.ZoomOut) + legacy.AdjustDistance(+adjustment); + if (input.Raise) + legacy.AdjustPitch(+adjustment * 0.02f); + if (input.Lower) + legacy.AdjustPitch(-adjustment * 0.02f); + if (input.RotateLeft) + legacy.YawOffset += adjustment * 0.02f; + if (input.RotateRight) + legacy.YawOffset -= adjustment * 0.02f; } if (!_localFrame.TryGetPresentationAfterNetwork(out var playerFrame)) diff --git a/src/AcDream.App/Rendering/ChaseCamera.cs b/src/AcDream.App/Rendering/ChaseCamera.cs index 10bf2111..f8cf01b4 100644 --- a/src/AcDream.App/Rendering/ChaseCamera.cs +++ b/src/AcDream.App/Rendering/ChaseCamera.cs @@ -10,6 +10,21 @@ namespace AcDream.App.Rendering; /// public sealed class ChaseCamera : ICamera { + private const float RetailDefaultBack = 2.5f; + private const float RetailDefaultUp = 0.75f; + private bool _lookingDown; + private bool _mapMode; + private bool _inHead; + private bool _savedInHead; + private float _savedDistance; + private float _savedPitch; + private float _savedYawOffset; + private Vector3? _targetDirectionLocal; + private Vector3? _savedTargetDirectionLocal; + + public bool IsLookingDown => _lookingDown; + public bool IsMapMode => _mapMode; + public bool IsInHead => _inHead; public Vector3 Position { get; private set; } public float Aspect { get; set; } = 16f / 9f; // #389: smartbox law at the 16:9 default aspect — see RetailFieldOfView. @@ -108,10 +123,35 @@ public sealed class ChaseCamera : ICamera float horizontalDist = Distance * MathF.Cos(Pitch); float verticalDist = Distance * MathF.Sin(Pitch); - Position = new Vector3( - playerPosition.X - forwardX * horizontalDist, - playerPosition.Y - forwardY * horizontalDist, - _trackedZ + EyeHeight + verticalDist); // ← uses tracked Z (pinned to ground while airborne) + if (_inHead) + { + Vector3 forward = new(MathF.Cos(playerYaw), MathF.Sin(playerYaw), 0f); + Position = new Vector3( + playerPosition.X, + playerPosition.Y, + _trackedZ + EyeHeight) + forward * 0.18f; + _lookAt = Position + forward; + } + else if (_targetDirectionLocal is { } localDirection) + { + Vector3 pivot = new(playerPosition.X, playerPosition.Y, _trackedZ + EyeHeight); + var directedPose = RetailChaseCamera.ComputeTargetDirectionPose( + pivot, + new Vector3(MathF.Cos(playerYaw), MathF.Sin(playerYaw), 0f), + Distance, + Pitch, + localDirection); + Position = directedPose.eye; + Vector3 direction = directedPose.forward; + _lookAt = Position + direction; + } + else + { + Position = new Vector3( + playerPosition.X - forwardX * horizontalDist, + playerPosition.Y - forwardY * horizontalDist, + _trackedZ + EyeHeight + verticalDist); // ← uses tracked Z (pinned to ground while airborne) + } } /// @@ -119,6 +159,8 @@ public sealed class ChaseCamera : ICamera /// public void AdjustPitch(float delta) { + ExitLookDownForAdjustment(); + ExitInHeadForAdjustment(); Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax); } @@ -127,6 +169,101 @@ public sealed class ChaseCamera : ICamera /// public void AdjustDistance(float delta) { + ExitLookDownForAdjustment(); + ExitInHeadForAdjustment(); Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax); } + + public void SetRetailDefaultView() + { + _lookingDown = false; + _mapMode = false; + _inHead = false; + _targetDirectionLocal = null; + YawOffset = 0f; + EyeHeight = 1.5f; + SetViewerOffset(RetailDefaultBack, RetailDefaultUp); + } + + public void SetRetailFirstPersonView() + { + _lookingDown = false; + _mapMode = false; + _inHead = true; + _targetDirectionLocal = null; + YawOffset = 0f; + Distance = 0.18f; + Pitch = 0f; + } + + public void ToggleRetailLookDownView() + { + if (_lookingDown) + { + RestoreLookDownView(); + return; + } + SaveLookDownView(); + _lookingDown = true; + _mapMode = false; + _inHead = false; + _targetDirectionLocal = new Vector3(0f, 0.5f, -1.8f); + SetViewerOffset(2f, RetailDefaultUp); + } + + public void ToggleRetailMapModeView() + { + if (_mapMode) + { + RestoreLookDownView(); + return; + } + if (!_lookingDown) + SaveLookDownView(); + _lookingDown = true; + _mapMode = true; + _inHead = false; + _targetDirectionLocal = new Vector3(0f, 0.5f, -1.8f); + SetViewerOffset(450f, RetailDefaultUp); + } + + private void SaveLookDownView() + { + _savedDistance = Distance; + _savedPitch = Pitch; + _savedYawOffset = YawOffset; + _savedTargetDirectionLocal = _targetDirectionLocal; + _savedInHead = _inHead; + } + + private void RestoreLookDownView() + { + Distance = _savedDistance; + Pitch = _savedPitch; + YawOffset = _savedYawOffset; + _targetDirectionLocal = _savedTargetDirectionLocal; + _inHead = _savedInHead; + _lookingDown = false; + _mapMode = false; + } + + private void ExitLookDownForAdjustment() + { + if (_lookingDown) + RestoreLookDownView(); + } + + private void ExitInHeadForAdjustment() + { + if (!_inHead) + return; + _inHead = false; + Distance = DistanceMin; + } + + private void SetViewerOffset(float back, float up) + { + Distance = MathF.Sqrt(back * back + up * up); + Pitch = MathF.Atan2(up, back); + } } diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 11ef0f3a..b454dd94 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1,5 +1,6 @@ using AcDream.Core.Plugins; using AcDream.App.Composition; +using AcDream.App.Input; using AcDream.App.Physics; using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Scene; @@ -17,6 +18,7 @@ using DatReaderWriter; using Silk.NET.Input; using Silk.NET.Maths; using Silk.NET.Windowing; +using AcDream.UI.Abstractions.Input; namespace AcDream.App.Rendering; @@ -599,14 +601,18 @@ public sealed class GameWindow : // startup — no other call to RetailDefaults() / AcdreamCurrentDefaults() // should land in the GameWindow construction path. private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings; + private bool _keyBindingsPersisted; private readonly GraphicalHostPlatformServices _platformServices; private readonly ApplicationPathSet _applicationPaths; private static AcDream.UI.Abstractions.Input.KeyBindings LoadStartupKeyBindings( string path) { - var bindings = AcDream.UI.Abstractions.Input.KeyBindings.LoadOrDefault(path); - Console.WriteLine($"keybinds: loaded {bindings.All.Count} bindings from {path}"); + var bindings = AcDream.App.Input.RetailKeymapProfileStore.LoadActiveOrJson( + path, out string profileName); + Console.WriteLine( + $"keybinds: loaded {bindings.All.Count} bindings; active retail profile " + + $"'{profileName}', JSON mirror {path}"); return bindings; } @@ -1522,7 +1528,8 @@ public sealed class GameWindow : hostInputCamera.GpuFrameLifetime, () => WorldTime.CurrentCalendar, settingsDevTools.RenderPacks, - _renderPackDiagnostics.CaptureDiagnostics), + _renderPackDiagnostics.CaptureDiagnostics, + _applicationPaths.ScreenshotsDirectory), _retailUiLease, this).Compose( platformResult, @@ -1569,6 +1576,7 @@ public sealed class GameWindow : _cellVisibility, _liveWorldOrigin, _localPlayerIdentity, + _chaseCameraInput, _pointerPosition, _playerApproachCompletions, _renderResourceLifetime, @@ -1821,6 +1829,7 @@ public sealed class GameWindow : if (!_lifetime.HasShutdownRoots) { + PersistKeyBindingsAtShutdown(); // Campaign LA slice LA1: capture BEFORE the shutdown roots run — // by the time teardown completes, IsInWorld is always false // regardless of whether a real session was ever connected. @@ -1861,6 +1870,33 @@ public sealed class GameWindow : ReportExited(report); } + private void PersistKeyBindingsAtShutdown() + { + // Construction-only tests and failed starts never create the input + // dispatcher. They must not materialize a profile in the real user's + // Documents folder merely because the half-built window is disposed. + if (_keyBindingsPersisted || _inputDispatcher is null) return; + _keyBindingsPersisted = true; + try + { + KeyBindings current = _inputDispatcher.Bindings; + var profiles = new AcDream.App.Input.RetailKeymapProfileStore( + _applicationPaths.KeyBindingsFile); + RetailKeymapSaveResult saved = profiles.SaveActive(current); + if (saved.Status != RetailKeymapSaveStatus.Saved) + { + Console.WriteLine( + $"keymap: shutdown save failed ({saved.Status}): {saved.Error}"); + return; + } + current.SaveToFile(_applicationPaths.KeyBindingsFile); + } + catch (Exception failure) + { + Console.WriteLine($"keymap: shutdown persistence failed: {failure.Message}"); + } + } + /// /// Writes the ONE terminal "exited" status event for this session /// (fix #406). A resource-shutdown transaction can converge cleanly diff --git a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs index b2502a1a..815a794c 100644 --- a/src/AcDream.App/Rendering/PaperdollFramePresenter.cs +++ b/src/AcDream.App/Rendering/PaperdollFramePresenter.cs @@ -14,6 +14,8 @@ internal interface IPaperdollDollRenderer { void SetDoll(WorldEntity? doll); + void Prepare(); + uint Render(int width, int height); } @@ -73,9 +75,6 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame public void Render() { - if (!_view.TryGetVisibleSize(out int width, out int height)) - return; - if (_dirty) { if (_factory.TryBuild(out WorldEntity? doll)) @@ -101,6 +100,11 @@ internal sealed class PaperdollFramePresenter : IPrivateEntityViewportFrame } } + _renderer.Prepare(); + + if (!_view.TryGetVisibleSize(out int width, out int height)) + return; + _view.SetTextureHandle(_renderer.Render(width, height)); } diff --git a/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs b/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs index 03309a72..f5aa80ff 100644 --- a/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs +++ b/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs @@ -42,6 +42,8 @@ public sealed class PaperdollViewportRenderer : public void SetDoll(WorldEntity? doll) => _renderer.SetEntity(doll); + public void Prepare() => _renderer.Prepare(); + public uint Render(int width, int height) => _renderer.Render(width, height); diff --git a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs index a5bc2e6a..84961aab 100644 --- a/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs +++ b/src/AcDream.App/Rendering/PrivateEntityViewportRenderer.cs @@ -94,6 +94,11 @@ internal sealed class PrivateEntityViewportRenderer : /// feature does not exist for them, not just "unused". private readonly EntitySlot? _backdropSlot; + // One stable sampled texture-table slot is part of the retained viewport's + // presentation contract. Rotating the slot with the Vulkan flight index + // made the UI sample a freshly-created/cleared sibling after world reveal. + // The frame submission order already protects this target's write -> sample + // transition; keep its identity stable until resize or disposal. private IGpuRenderTarget? _target; private IGpuSampler? _sampler; private GpuTextureSlot _slot = GpuTextureSlot.Unassigned; @@ -170,6 +175,29 @@ internal sealed class PrivateEntityViewportRenderer : public void SetEntity(WorldEntity? entity) => _mainSlot.Set(entity); + /// + /// Advances the private entity's mesh and texture-composite readiness + /// without allocating or clearing a render target. Paperdoll uses this + /// while its tab is hidden so first-open work is already resident. + /// + public bool Prepare() + { + if (!_mainSlot.PrepareForDraw() + || !(_backdropSlot?.PrepareForDraw() ?? true)) + { + return false; + } + WorldEntity? entity = _mainSlot.Entity; + if (entity is null || entity.MeshRefs.Count == 0) + { + return false; + } + IReadOnlyList entities = BuildDrawEntities( + _backdropSlot?.Entity, + entity); + return _dispatcher.PreparePrivateEntityResources(entities); + } + /// /// Sets or clears the environment backdrop entity drawn BEHIND the main /// entity — GF-7/GF-14's fix, retail's gmCG3DView::m_pbgObject. Only @@ -219,6 +247,16 @@ internal sealed class PrivateEntityViewportRenderer : if (entity is null || entity.MeshRefs.Count == 0 || width <= 0 || height <= 0) return 0u; + IReadOnlyList drawEntities = BuildDrawEntities( + _backdropSlot?.Entity, + entity); + if (!_dispatcher.PreparePrivateEntityResources(drawEntities)) + { + return _hasRenderedScene && _slot.IsAssigned + ? UiTextureTableHandle.FromSlot(_slot) + : 0u; + } + EnsureRenderTarget(width, height); if (_target is null) return 0u; @@ -254,7 +292,6 @@ internal sealed class PrivateEntityViewportRenderer : UploadCreatureLight(); - IReadOnlyList drawEntities = BuildDrawEntities(_backdropSlot?.Entity, entity); var entries = new (uint, Vector3, Vector3, IReadOnlyList, IReadOnlyDictionary?)[] diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs index 991466e1..5362b457 100644 --- a/src/AcDream.App/Rendering/RetailChaseCamera.cs +++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs @@ -29,6 +29,12 @@ namespace AcDream.App.Rendering; /// public sealed class RetailChaseCamera : ICamera { + private const float RetailDefaultBack = 2.5f; + private const float RetailDefaultUp = 0.75f; + private const float RetailLookDownBack = 2f; + private const float RetailMapBack = 450f; + private const float RetailFirstPersonForward = 0.18f; + // ICamera surface. public Vector3 Position { get; private set; } @@ -75,6 +81,20 @@ public sealed class RetailChaseCamera : ICamera /// Height of look-at anchor above the player's feet (m). Retail default 1.5. public float PivotHeight { get; set; } = 1.5f; + private bool _lookingDown; + private bool _mapMode; + private bool _inHead; + private bool _savedInHead; + private float _savedDistance; + private float _savedPitch; + private float _savedYawOffset; + private Vector3? _targetDirectionLocal; + private Vector3? _savedTargetDirectionLocal; + + public bool IsLookingDown => _lookingDown; + public bool IsMapMode => _mapMode; + public bool IsInHead => _inHead; + /// /// Optional spring-arm collision probe. When set (and /// is true), the damped eye @@ -172,8 +192,13 @@ public sealed class RetailChaseCamera : ICamera // target supplies the frame heading. Without this local rotation, enabling // Keep in View snaps the camera behind the target and disables RMB orbit. float viewerYawOffset = trackedHeading.HasValue ? YawOffset : 0f; - (Vector3 targetEye, Vector3 targetForward) = ComputeDesiredPose( - pivotWorld, heading, Distance, Pitch, viewerYawOffset); + (Vector3 targetEye, Vector3 targetForward) = _inHead + ? ComputeInHeadPose(pivotWorld, heading) + : _targetDirectionLocal is { } localDirection + ? ComputeTargetDirectionPose( + pivotWorld, heading, Distance, Pitch, localDirection) + : ComputeDesiredPose( + pivotWorld, heading, Distance, Pitch, viewerYawOffset); // 5. Stateful sought position (#180). Retail CameraManager::UpdateCamera // (0x00456660) interpolates FROM THE CURRENT SWEPT VIEWER toward the @@ -279,16 +304,120 @@ public sealed class RetailChaseCamera : ICamera /// ... Mirrors /// legacy ChaseCamera.AdjustDistance. /// - public void AdjustDistance(float delta) => + public void AdjustDistance(float delta) + { + ExitLookDownForAdjustment(); + ExitInHeadForAdjustment(); Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax); + } /// /// Adjust the camera pitch by a delta (radians), clamped to /// ... Mirrors legacy /// ChaseCamera.AdjustPitch. /// - public void AdjustPitch(float delta) => + public void AdjustPitch(float delta) + { + ExitLookDownForAdjustment(); + ExitInHeadForAdjustment(); Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax); + } + + public void SetRetailDefaultView() + { + _lookingDown = false; + _mapMode = false; + _inHead = false; + _targetDirectionLocal = null; + YawOffset = 0f; + PivotHeight = 1.5f; + SetViewerOffset(RetailDefaultBack, RetailDefaultUp); + } + + public void SetRetailFirstPersonView() + { + _lookingDown = false; + _mapMode = false; + _inHead = true; + _targetDirectionLocal = null; + YawOffset = 0f; + Distance = RetailFirstPersonForward; + Pitch = 0f; + // Do not spend a transition frame inside the head/neck. Retail's + // SetInHead installs the new viewer offset as one camera preset. + _initialised = false; + } + + public void ToggleRetailLookDownView() + { + if (_lookingDown) + { + RestoreLookDownView(); + return; + } + SaveLookDownView(); + _lookingDown = true; + _mapMode = false; + _inHead = false; + _targetDirectionLocal = new Vector3(0f, 0.5f, -1.8f); + SetViewerOffset(RetailLookDownBack, RetailDefaultUp); + } + + public void ToggleRetailMapModeView() + { + if (_mapMode) + { + RestoreLookDownView(); + return; + } + if (!_lookingDown) + SaveLookDownView(); + _lookingDown = true; + _mapMode = true; + _inHead = false; + _targetDirectionLocal = new Vector3(0f, 0.5f, -1.8f); + SetViewerOffset(RetailMapBack, RetailDefaultUp); + } + + private void SaveLookDownView() + { + _savedDistance = Distance; + _savedPitch = Pitch; + _savedYawOffset = YawOffset; + _savedTargetDirectionLocal = _targetDirectionLocal; + _savedInHead = _inHead; + } + + private void RestoreLookDownView() + { + Distance = _savedDistance; + Pitch = _savedPitch; + YawOffset = _savedYawOffset; + _targetDirectionLocal = _savedTargetDirectionLocal; + _inHead = _savedInHead; + _lookingDown = false; + _mapMode = false; + } + + private void ExitLookDownForAdjustment() + { + if (_lookingDown) + RestoreLookDownView(); + } + + private void ExitInHeadForAdjustment() + { + if (!_inHead) + return; + _inHead = false; + Distance = DistanceMin; + } + + private void SetViewerOffset(float back, float up) + { + Distance = MathF.Sqrt(back * back + up * up); + Pitch = MathF.Atan2(up, back); + } /// /// Public entry point for the mouse-input low-pass filter. Calls @@ -436,6 +565,47 @@ public sealed class RetailChaseCamera : ICamera return (eye, forward); } + /// + /// Retail CameraSet::SetInHead @ 0x00458CE0: the target direction + /// is local +Y and the viewer offset is local +Y * 0.18. It is not a + /// negative chase boom looking back toward the player's neck. + /// + internal static (Vector3 eye, Vector3 forward) ComputeInHeadPose( + Vector3 pivotWorld, + Vector3 heading) + { + Vector3 forward = Vector3.Normalize(heading); + return (pivotWorld + forward * RetailFirstPersonForward, forward); + } + + /// + /// Transform both retail viewer_offset and target_direction + /// through the target frame. LookDown/MapMode do not merely point a + /// horizontally-positioned camera toward the ground: the downward target + /// direction pitches the frame whose -Y/+Z offset places the viewer. For + /// MapMode's (0,-450,0.75) offset this puts the viewer high above and only + /// slightly behind the character, matching CameraSet::SetMapMode. + /// + internal static (Vector3 eye, Vector3 forward) ComputeTargetDirectionPose( + Vector3 pivotWorld, + Vector3 heading, + float distance, + float pitch, + Vector3 targetDirectionLocal) + { + var (frameForward, frameRight, frameUp) = BuildBasis(heading); + Vector3 targetForward = Vector3.Normalize( + frameForward * targetDirectionLocal.Y + - frameRight * targetDirectionLocal.X + + frameUp * targetDirectionLocal.Z); + var (_, _, targetUp) = BuildBasis(targetForward); + + float back = distance * MathF.Cos(pitch); + float up = distance * MathF.Sin(pitch); + Vector3 eye = pivotWorld - targetForward * back + targetUp * up; + return (eye, targetForward); + } + /// /// Build an orthonormal basis with forward = heading. World /// up is (0, 0, 1); if heading is near-parallel to it diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs index e701d3fc..d3df7aef 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs @@ -473,6 +473,7 @@ public sealed unsafe partial class WbDrawDispatcher } float opacity = PackedPartOpacity( + entity.ServerGuid, entity.LocalEntityId, (uint)setupPartIndex); if (opacity < 1f) @@ -527,6 +528,7 @@ public sealed unsafe partial class WbDrawDispatcher // one-part assumption and kept the Bind Stone's four // hook-hidden shard parts visible. float opacity = PackedPartOpacity( + entity.ServerGuid, entity.LocalEntityId, (uint)partIndex); if (opacity < 1f) @@ -585,20 +587,24 @@ public sealed unsafe partial class WbDrawDispatcher anyVao != 0 && alphaQueueCollecting; private float PackedPartOpacity( + uint serverGuid, uint localEntityId, uint setupPartIndex) { + float opacity = EntityOpacity(serverGuid); + if (opacity <= 0f) + return 0f; if (!_translucencyFades.TryGetCurrentValue( localEntityId, setupPartIndex, out float translucency)) { - return 1f; + return opacity; } return translucency >= 1f ? 0f - : 1f - translucency; + : opacity * (1f - translucency); } private bool ClassifyPackedBatches( diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs index 25d3dc17..30b22946 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs @@ -176,7 +176,8 @@ public sealed unsafe partial class WbDrawDispatcher RetailAlphaQueue? alphaQueue = null, long? alphaScratchBudgetBytes = null, TerrainAtlas.RetailDetailTextureBinding buildingDetail = default, - Func? buildingDetailEnabled = null) + Func? buildingDetailEnabled = null, + Func? hierarchicalTranslucency = null) { _device = device ?? throw new ArgumentNullException(nameof(device)); _frames = frames ?? throw new ArgumentNullException(nameof(frames)); @@ -192,6 +193,7 @@ public sealed unsafe partial class WbDrawDispatcher _selectionSink = selectionSink; _selectionLighting = selectionSink as IRetailSelectionLightingSource; _alphaQueue = alphaQueue; + _hierarchicalTranslucency = hierarchicalTranslucency; _alphaSource = new AlphaDrawSource(this); _buildingDetail = buildingDetail; _buildingDetailEnabled = buildingDetailEnabled ?? DisableDetailTextures; diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 3c1f4bd9..3ea377bb 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -91,6 +91,7 @@ public sealed partial class WbDrawDispatcher : IDisposable private readonly IRetailSelectionRenderSink? _selectionSink; private readonly IRetailSelectionLightingSource? _selectionLighting; private readonly RetailAlphaQueue? _alphaQueue; + private readonly Func? _hierarchicalTranslucency; private readonly AlphaDrawSource _alphaSource; private readonly RetainedScratchCapacityPolicy _alphaScratchPolicy; private int _scratchPeakUnits; @@ -1784,11 +1785,12 @@ public sealed partial class WbDrawDispatcher : IDisposable // sets draw_state|=1 and skips the whole part outright — not a // blend to nothing. TranslucencyFadeManager.AdvanceAll guarantees // t=1 commits the bitwise-exact value so this check is safe. - float opacityMultiplier = 1.0f; + float opacityMultiplier = EntityOpacity(entity.ServerGuid); + if (opacityMultiplier <= 0f) continue; if (_translucencyFades.TryGetCurrentValue(entity.Id, (uint)setupPartIndex, out float translucencyValue)) { if (translucencyValue >= 1.0f) continue; // skip this part's draw entirely - opacityMultiplier = 1f - translucencyValue; // CMaterial::SetTranslucencySimple 0x005396f0 + opacityMultiplier *= 1f - translucencyValue; // CMaterial::SetTranslucencySimple 0x005396f0 } if (!ClassifyBatches(partData, model, entity, meshRef, paletteIdentity, restPose, opacityMultiplier, collector, entityHasCutoutSubset)) @@ -1818,12 +1820,14 @@ public sealed partial class WbDrawDispatcher : IDisposable // entity — the Bind Stone's idle cycle hides its four authored // shard parts (3-6) with TransparentPartHook start=end=1.0 // every loop, and they stayed visible. - float opacityMultiplier = 1.0f; + float opacityMultiplier = EntityOpacity(entity.ServerGuid); bool fullyInvisible = false; + if (opacityMultiplier <= 0f) + fullyInvisible = true; if (_translucencyFades.TryGetCurrentValue(entity.Id, (uint)partIdx, out float translucencyValue)) { if (translucencyValue >= 1.0f) fullyInvisible = true; - else opacityMultiplier = 1f - translucencyValue; + else opacityMultiplier *= 1f - translucencyValue; } if (!fullyInvisible) @@ -1901,6 +1905,32 @@ public sealed partial class WbDrawDispatcher : IDisposable observeCurrentPath: true); } + /// + /// Readiness barrier for a private creature viewport. Unlike the world + /// reveal queue this has no retained scan state: the caller owns a tiny, + /// exact entity list and retries it each frame. A completed result means + /// both mesh render data and every palette/original-texture composite can + /// be classified without clearing the private target to an empty frame. + /// + internal bool PreparePrivateEntityResources( + IReadOnlyList entities) + { + ArgumentNullException.ThrowIfNull(entities); + bool complete = true; + for (int i = 0; i < entities.Count; i++) + { + if (PrepareCompositeEntity(entities[i]) != CompositeWarmupResult.Complete) + complete = false; + } + return complete; + } + + private float EntityOpacity(uint serverGuid) + { + float translucency = _hierarchicalTranslucency?.Invoke(serverGuid) ?? 0f; + return 1f - Math.Clamp(translucency, 0f, 1f); + } + /// /// Whether there is a mesh source to draw from. The encoder arm has no /// vertex array of its own — the pipeline owns one shaped by diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs index dd8c811b..2e15bffe 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeCommandAdapter.cs @@ -317,6 +317,28 @@ internal sealed class CurrentGameRuntimeCommandAdapter return Result(status); } + public RuntimeCommandResult ExecuteMotion( + RuntimeGenerationToken expectedGeneration, + uint motionCommand) + { + RuntimeCommandStatus gate = Validate( + expectedGeneration, + requireWorld: true); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + + RuntimeCommandStatus status = _movement.ExecuteMotion(motionCommand) + ? RuntimeCommandStatus.Accepted + : RuntimeCommandStatus.Unsupported; + + _events.EmitCommand( + RuntimeCommandDomain.Movement, + operation: 0x102, + status, + motionCommand); + return Result(status, motionCommand); + } + public RuntimeCommandResult SetIntent( RuntimeGenerationToken expectedGeneration, in MovementInput input) diff --git a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs index 480e3f7a..22e87446 100644 --- a/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs +++ b/src/AcDream.App/Streaming/LocalPlayerTeleportController.cs @@ -736,6 +736,16 @@ internal sealed class LocalPlayerTeleportController /// private readonly ILocalPlayerLogoutOperations _logout; + /// + /// The confirmed-logoff pump must let the old streaming window finish + /// its asynchronous session retirement before Runtime exposes the next + /// character generation. The reset transaction immediately calls back + /// into ; this latch transfers + /// that already-completed retirement across the synchronous callback so + /// it is consumed once instead of starting a second old-window pass. + /// + private bool _logoutStreamingRetirementPrepared; + public LocalPlayerTeleportController( ILocalPlayerTeleportAuthority authority, ILocalPlayerTeleportInputLifetime input, @@ -984,6 +994,8 @@ internal sealed class LocalPlayerTeleportController return false; } + _logoutStreamingRetirementPrepared = false; + if (!_transit.TryBeginLogoutRequest(_logout.IsLocalPlayerKiller)) return false; @@ -1088,8 +1100,23 @@ internal sealed class LocalPlayerTeleportController private void CompleteLogoutHandoff(long generation) { - if (!_transit.CompleteLogout() || _lifetimeGeneration != generation) + // Reset(sessionEnding: true) is a retained, frame-budgeted old-world + // retirement. Do not let CompleteCharacterLogOff expose the fresh + // Runtime generation until that barrier has converged; otherwise a + // quick re-entry can inherit the origin-recenter gate and remain in + // portal space with no landblocks admitted (lb 0/0). + if (!_streaming.ResetRecenter(sessionEnding: true) + || _lifetimeGeneration != generation) + { return; + } + + _logoutStreamingRetirementPrepared = true; + if (!_transit.CompleteLogout() || _lifetimeGeneration != generation) + { + _logoutStreamingRetirementPrepared = false; + return; + } Console.WriteLine( "live: logout confirmed — returning to character select"); @@ -1102,6 +1129,8 @@ internal sealed class LocalPlayerTeleportController return; } + _logoutStreamingRetirementPrepared = false; + // The transaction refused or degraded to a full stop. If a reset // reached this controller the lifetime moved and everything is // already clean; otherwise retire the presentation here so a @@ -1927,6 +1956,11 @@ internal sealed class LocalPlayerTeleportController bool clearSession, bool resetCanonicalTransit = false) { + bool streamingRetirementPrepared = clearSession + && _logoutStreamingRetirementPrepared; + if (clearSession) + _logoutStreamingRetirementPrepared = false; + long generation = checked(++_lifetimeGeneration); _pendingCell = 0u; @@ -1951,7 +1985,8 @@ internal sealed class LocalPlayerTeleportController if (clearSession) _loginPlacementCompleted = false; - _streaming.ResetRecenter(clearSession); + if (!streamingRetirementPrepared) + _streaming.ResetRecenter(clearSession); if (_lifetimeGeneration != generation) return generation; diff --git a/src/AcDream.App/UI/AutoWieldController.cs b/src/AcDream.App/UI/AutoWieldController.cs index e0cd8d66..ea21a67a 100644 --- a/src/AcDream.App/UI/AutoWieldController.cs +++ b/src/AcDream.App/UI/AutoWieldController.cs @@ -62,10 +62,10 @@ internal sealed class AutoWieldController : IDisposable private readonly Func _playerGuid; private readonly Action? _sendWield; private readonly Action? _sendPutItemInContainer; - private readonly Action? _toast; private readonly Action? _systemMessage; private readonly CombatState? _combatState; private readonly Action? _sendChangeCombatMode; + private readonly InventoryTransactionState? _transactions; private PendingSwitch? _pendingSwitch; private PendingCombatSettlement? _pendingCombatSettlement; @@ -79,19 +79,19 @@ internal sealed class AutoWieldController : IDisposable Func playerGuid, Action? sendWield, Action? sendPutItemInContainer, - Action? toast, Action? systemMessage = null, CombatState? combatState = null, - Action? sendChangeCombatMode = null) + Action? sendChangeCombatMode = null, + InventoryTransactionState? transactions = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); _sendWield = sendWield; _sendPutItemInContainer = sendPutItemInContainer; - _toast = toast; _systemMessage = systemMessage; _combatState = combatState; _sendChangeCombatMode = sendChangeCombatMode; + _transactions = transactions; _objects.ObjectMoved += OnObjectMoved; _objects.ObjectRemoved += OnObjectRemoved; @@ -238,8 +238,20 @@ internal sealed class AutoWieldController : IDisposable : BestAvailableEquipMask(item); if (mask == EquipMask.None) { - _toast?.Invoke("That slot is already in use"); - return false; + // UsingItem calls retail AutoWield with its automatic-unblock flag. + // When every compatible slot is occupied, retail chooses the first + // compatible slot, moves that blocker to the backpack, and retries + // only after RecvNotice_ServerSaysMoveItem confirms the move. + // CPlayerSystem::AutoWield @ 0x0056173D-0x0056186E. + mask = FirstCompatibleEquipMask(item); + ClientObject? blocker = GetEquippedObjectAtLocation( + mask, priority: 0, item.ObjectId); + return blocker is not null + && BeginWeaponReplacement( + item.ObjectId, + blocker, + mask, + combatModeAfterWield: null); } return SendWield(item, mask, combatModeAfterWield: null); @@ -252,10 +264,7 @@ internal sealed class AutoWieldController : IDisposable CombatMode? combatModeAfterWield) { if (_sendPutItemInContainer is null) - { - _toast?.Invoke("That slot is already in use"); return false; - } uint player = _playerGuid(); if (player == 0) @@ -272,8 +281,17 @@ internal sealed class AutoWieldController : IDisposable // is the transaction boundary and preserves its stance-specific motion. _systemMessage?.Invoke( $"Moving {blockingItem.GetAppropriateName()} to your backpack"); - _sendPutItemInContainer(blockingItem.ObjectId, player, 0); - return true; + bool dispatched = DispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + blockingItem.ObjectId, + () => + { + _sendPutItemInContainer(blockingItem.ObjectId, player, 0); + return true; + }); + if (!dispatched) + _pendingSwitch = null; + return dispatched; } private bool SendWield( @@ -288,17 +306,26 @@ internal sealed class AutoWieldController : IDisposable BlockingItemId: 0, RequestedMask: mask, CombatModeAfterWield: combatModeAfterWield); - if (!_objects.WieldItemOptimistic(item.ObjectId, _playerGuid(), mask)) - { - _pendingSwitch = null; - return false; - } - // Retail ACCWeenieObject::UIAttemptWield @ 0x0058D590. - _sendWield(item.ObjectId, (uint)mask); - return true; + bool dispatched = DispatchInventoryRequest( + InventoryRequestKind.Wield, + item.ObjectId, + () => + { + _sendWield(item.ObjectId, (uint)mask); + return true; + }); + if (!dispatched) + _pendingSwitch = null; + return dispatched; } + private bool DispatchInventoryRequest( + InventoryRequestKind kind, + uint itemId, + Func dispatch) + => _transactions?.TryDispatch(kind, itemId, dispatch) ?? dispatch(); + private void OnObjectMoved(ClientObjectMove move) { if (_pendingSwitch is not { } pending @@ -454,6 +481,14 @@ internal sealed class AutoWieldController : IDisposable return EquipMask.None; } + private static EquipMask FirstCompatibleEquipMask(ClientObject item) + { + foreach (EquipMask mask in AutoEquipOrder) + if ((item.ValidLocations & mask) != EquipMask.None) + return mask; + return EquipMask.None; + } + private bool AutoWearIsLegal( ClientObject item, out ClientObject? blocker) diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index a0515a53..6e767cab 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -43,6 +43,7 @@ public sealed class ItemInteractionController : IDisposable private readonly Action? _sendSplitToWorld; private readonly Action? _sendPutItemInContainer; private readonly Action? _sendSplitToContainer; + private readonly Action? _sendStackableMerge; private readonly Action? _sendGive; private readonly Action? _toast; private readonly Func _readyForInventoryRequest; @@ -118,7 +119,8 @@ public sealed class ItemInteractionController : IDisposable Func? sendBuy = null, Func, uint, bool>? sendBuyAll = null, Func, bool>? sendSell = null, - Action? interfaceText = null) + Action? interfaceText = null, + Action? sendStackableMerge = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); @@ -130,6 +132,7 @@ public sealed class ItemInteractionController : IDisposable _sendSplitToWorld = sendSplitToWorld; _sendPutItemInContainer = sendPutItemInContainer; _sendSplitToContainer = sendSplitToContainer; + _sendStackableMerge = sendStackableMerge; _sendGive = sendGive; _nowMs = nowMs ?? (() => Environment.TickCount64); _toast = toast; @@ -168,10 +171,10 @@ public sealed class ItemInteractionController : IDisposable _playerGuid, _sendWield, sendPutItemInContainer, - _toast, _systemMessage, combatState, - sendChangeCombatMode); + sendChangeCombatMode, + _transactions); _interactionState.Changed += OnInteractionModeChanged; _transactions.StateChanged += OnTransactionStateChanged; _transactions.RequestCompleted += OnInventoryRequestCompleted; @@ -182,6 +185,12 @@ public sealed class ItemInteractionController : IDisposable public event Action? StateChanged; + /// + /// Retail ItemHolder::AttemptMerge immediately selects the target + /// stack and publishes the toolbar merge-attempt notice after dispatch. + /// + public event Action? MergeAttempted; + /// /// Retail's two secure-trade open paths surface here for the trade UI: /// (partnerGuid, itemGuid) — itemGuid 0 for Use-on-player @@ -457,6 +466,40 @@ public sealed class ItemInteractionController : IDisposable public bool TryGetPendingInventoryRequest(out PendingInventoryRequest pending) => _transactions.TryGetPending(out pending); + /// + /// Retail ACCWeenieObject::UIAttemptSplitToContainer: split an + /// exact partial quantity into a container through the canonical + /// one-request inventory gate. The source remains in place until the + /// authoritative stack update and newly-created split object arrive. + /// + public bool TrySplitToContainer( + uint itemId, + uint containerId, + uint placement, + uint amount) + { + if (itemId == 0u + || containerId == 0u + || _sendSplitToContainer is null + || _objects.Get(itemId) is not { } item) + { + return false; + } + + uint fullStack = (uint)Math.Max(1, item.StackSize); + if (amount == 0u || amount >= fullStack) + return false; + + return TryDispatchInventoryRequest( + InventoryRequestKind.SplitToContainer, + itemId, + () => + { + _sendSplitToContainer(itemId, containerId, placement, amount); + return true; + }); + } + /// /// Increments retail's shared ClientUISystem busy reference after a /// request issued by another retained controller has been sent. The @@ -486,6 +529,29 @@ public sealed class ItemInteractionController : IDisposable public bool IsPendingSource(uint itemGuid) => itemGuid != 0 && itemGuid == PendingSourceItem; + /// + /// True while retail's global inventory request latch owns this physical + /// item. Retained item lists use it for the waiting/ghosted source visual; + /// canonical placement remains unchanged until the server response. + /// + public bool IsPendingInventorySource(uint itemGuid) + => itemGuid != 0 + && _transactions.TryGetPending(out PendingInventoryRequest pending) + && pending.ItemId == itemGuid; + + /// Route a literal local refusal to retail's SpewBox channel. + public void ReportClientLocal(string message) + { + if (string.IsNullOrWhiteSpace(message)) + return; + if (_interfaceText is not null) + _interfaceText(message, RetailLogTextType.ClientLocal); + else if (_systemMessage is not null) + _systemMessage(message); + else + _toast?.Invoke(message); + } + /// /// Retail ACCWeenieObject::IsOwnedByPlayer projection shared with /// toolbar shortcut creation. @@ -689,15 +755,46 @@ public sealed class ItemInteractionController : IDisposable /// publishes the waiting destination slot before issuing the move request, /// exactly like double-click pickup through ItemHolder. /// - public bool PlaceWorldItemInBackpack(uint itemGuid) + public bool PlaceWorldItemInBackpack(uint itemGuid, bool mainPack = false) { if (itemGuid == 0u || _placeInBackpack is null) return false; - uint containerId = _backpackContainerId(); + uint containerId = mainPack ? _playerGuid() : _backpackContainerId(); if (containerId == 0u) containerId = _playerGuid(); const int placement = 0; + + // CPlayerSystem::PlaceInBackpack passes autoMerge=true to + // ItemHolder::AttemptToPlaceInContainer. Retail searches the player's + // exhaustive carried inventory first and only merges when one target + // can accept the complete selected split quantity. + if (TryPlanAutoMerge(itemGuid) is { } merge) + { + if (!TryDispatchPendingBackpackPlacement( + itemGuid, + containerId, + placement, + InventoryRequestKind.Merge, + () => + { + _sendStackableMerge!( + merge.SourceObjectId, + merge.TargetObjectId, + merge.Amount); + MergeAttempted?.Invoke( + merge.SourceObjectId, + merge.TargetObjectId); + return true; + })) + { + // As with ordinary pickup, retail consumes the key while the + // shared inventory-request gate is busy. + return true; + } + return true; + } + if (!TryBeginPendingBackpackPlacement( itemGuid, containerId, @@ -716,6 +813,67 @@ public sealed class ItemInteractionController : IDisposable return true; } + private StackMergePlan? TryPlanAutoMerge(uint sourceId) + { + if (_sendStackableMerge is null + || _objects.Get(sourceId) is not { } source + || source.StackSizeMax <= 1) + { + return null; + } + + uint requested = _stackSplitQuantity?.GetObjectSplitSize( + sourceId, + _selectedObjectId(), + (uint)Math.Max(1, source.StackSize)) + ?? (uint)Math.Max(1, source.StackSize); + int requestedAmount = (int)Math.Min(requested, int.MaxValue); + var sourceMerge = ToStackMergeItem(source); + uint player = _playerGuid(); + if (player == 0u) + return null; + + var visitedContainers = new HashSet(); + foreach (uint targetId in ExhaustiveContents(player, visitedContainers)) + { + if (_objects.Get(targetId) is not { } target) + continue; + StackMergePlan? plan = StackMergePlanner.Plan( + sourceMerge, + ToStackMergeItem(target), + CanMakeInventoryRequest, + requestedAmount); + // AttemptAutoMerge rejects a partial fit and keeps searching. + if (plan is { } complete && complete.Amount == requested) + return complete; + } + return null; + } + + private IEnumerable ExhaustiveContents( + uint containerId, + HashSet visitedContainers) + { + if (!visitedContainers.Add(containerId)) + yield break; + + foreach (uint itemId in _objects.GetContents(containerId)) + { + yield return itemId; + if (_objects.GetContents(itemId).Count == 0) + continue; + foreach (uint nested in ExhaustiveContents(itemId, visitedContainers)) + yield return nested; + } + } + + private static StackMergeItem ToStackMergeItem(ClientObject item) => new( + item.ObjectId, + item.WeenieClassId, + item.StackSize, + item.StackSizeMax, + item.TradeState); + public bool TryBeginPendingBackpackPlacement( uint itemGuid, uint containerId, @@ -1043,6 +1201,14 @@ public sealed class ItemInteractionController : IDisposable public bool DropToWorld(ItemDragPayload payload) => PlaceIn3D(payload, targetGuid: 0u); + /// + /// Keyboard equivalent of dropping the selected inventory item into the + /// 3-D view. Retail routes Give Selected and Drop Selected through the + /// same ItemHolder::AttemptPlaceIn3D @ 0x00588600 policy as a drag. + /// + public bool PlaceSelectedIn3D(uint itemGuid, uint targetGuid) + => PlaceIn3D(itemGuid, ItemDragSource.Inventory, targetGuid); + /// /// Retail inventory drag released into SmartBox. The release target is the /// world object under the cursor, or zero for empty ground. This is the live @@ -1052,9 +1218,17 @@ public sealed class ItemInteractionController : IDisposable { ArgumentNullException.ThrowIfNull(payload); - if (payload.SourceKind == ItemDragSource.ShortcutBar) + return PlaceIn3D(payload.ObjId, payload.SourceKind, targetGuid); + } + + private bool PlaceIn3D( + uint itemGuid, + ItemDragSource sourceKind, + uint targetGuid) + { + if (sourceKind == ItemDragSource.ShortcutBar) return false; - if (payload.ObjId == 0 || _objects.Get(payload.ObjId) is not { } item) + if (itemGuid == 0 || _objects.Get(itemGuid) is not { } item) return false; if (!EnsureInventoryRequestReady()) return false; @@ -1154,7 +1328,7 @@ public sealed class ItemInteractionController : IDisposable break; case ItemPolicyActionKind.Reject: if (!string.IsNullOrWhiteSpace(action.Message)) - _toast?.Invoke(action.Message); + ReportClientLocal(action.Message); break; case ItemPolicyActionKind.OpenSecureTrade: // Use-on-player (ItemHolder::DetermineUseResult @@ -1169,8 +1343,9 @@ public sealed class ItemInteractionController : IDisposable PolicyActionRequested?.Invoke(action); bool handled = _auxiliaryAction is not null || PolicyActionRequested is not null; if (!handled) - _toast?.Invoke(PolicyActionMessage(action)); - acted |= handled || _toast is not null; + ReportClientLocal(PolicyActionMessage(action)); + acted |= handled || _interfaceText is not null + || _systemMessage is not null || _toast is not null; break; } } @@ -1199,14 +1374,8 @@ public sealed class ItemInteractionController : IDisposable action.ObjectId, () => { - if (_sendDrop is null - || !_objects.MoveItemOptimistic( - action.ObjectId, - newContainerId: 0u, - newSlot: -1)) - { + if (_sendDrop is null) return false; - } _sendDrop(action.ObjectId); return true; }); @@ -1290,13 +1459,13 @@ public sealed class ItemInteractionController : IDisposable } case ItemPolicyActionKind.Reject: if (!string.IsNullOrWhiteSpace(action.Message)) - _toast?.Invoke(action.Message); + ReportClientLocal(action.Message); break; default: _auxiliaryAction?.Invoke(action); PolicyActionRequested?.Invoke(action); if (_auxiliaryAction is null && PolicyActionRequested is null) - _toast?.Invoke(PolicyActionMessage(action)); + ReportClientLocal(PolicyActionMessage(action)); break; } } @@ -1308,7 +1477,7 @@ public sealed class ItemInteractionController : IDisposable _interactionState.EnterUseItemOnTarget(sourceGuid); var name = _objects.Get(sourceGuid)?.Name; if (!string.IsNullOrWhiteSpace(name)) - _toast?.Invoke($"Choose a target for the {name}"); + ReportClientLocal($"Choose a target for the {name}"); } private void ClearTargetMode() @@ -1387,8 +1556,6 @@ public sealed class ItemInteractionController : IDisposable PendingInventoryRequest request, uint weenieError) { - if (_interfaceText is null) - return; ClientObject? item = request.ItemIdentity ?? _objects.Get(request.ItemId); if (item is null) return; @@ -1407,7 +1574,7 @@ public sealed class ItemInteractionController : IDisposable if (InventoryFailureMessages.Compose(request.Kind, name, weenieError) is { } text) { - _interfaceText(text, RetailLogTextType.ClientLocal); + ReportClientLocal(text); } } @@ -1443,6 +1610,7 @@ public sealed class ItemInteractionController : IDisposable _transactions.RequestCompleted -= OnInventoryRequestCompleted; _transactions.StateChanged -= OnTransactionStateChanged; WorldDropDispatched = null; + MergeAttempted = null; _autoWield.Dispose(); } @@ -1575,7 +1743,8 @@ public sealed class ItemInteractionController : IDisposable stackSize, stackSize, IsIn3DView: item.ContainerId == 0 && item.WielderId == 0 - && item.ObjectId != _playerGuid()); + && item.ObjectId != _playerGuid(), + Name: item.GetAppropriateName()); } /// diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index dd48e83d..d6097c21 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -255,13 +255,23 @@ public static class CharacterStatController // RetailScrollbarChrome (2026-08-24: the previous local constants seated // the DOWN-arrow art on the top button). - private enum CharacterStatTab + public enum CharacterStatTab { Attributes, Skills, Titles, } + /// + /// Live character-panel binding. Keyboard panel actions use the same + /// tab switch function as the authored tab buttons, so F8/F9 cannot + /// diverge from click behavior. + /// + public sealed record Binding( + Action Refresh, + Action ShowTab, + Func CurrentTab); + public enum RaiseTargetKind { Attribute, @@ -386,7 +396,7 @@ public static class CharacterStatController /// next click. The caller invokes this from the sheet-changed /// subscription. /// - public static Action Bind( + public static Binding Bind( ImportedLayout layout, Func data, UiDatFont? datFont = null, @@ -881,7 +891,10 @@ public static class CharacterStatController // luminance-award quality change. } - return () => RefreshAfterRaise(null); + return new Binding( + () => RefreshAfterRaise(null), + SwitchTab, + () => activeTab[0]); } private static UiScrollbar? PrepareSkillScrollbar( diff --git a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs index 21e9071d..a8f407c9 100644 --- a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs +++ b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs @@ -100,9 +100,10 @@ internal static class ChatTranscriptRenderer /// accumulating, so the two-threshold hysteresis has nothing to damp — it /// exists to stop retail trimming on every single append. A single cap /// gives a STABLE window here; oscillating one would make the oldest - /// visible line jump around as messages arrive. Cutting at whole lines is - /// automatic for the same reason: our unit already is the line, which is - /// what retail's newline preference is trying to achieve. + /// visible line jump around as messages arrive. Most entries are already + /// one line; an oversized server entry with embedded newlines is clipped + /// at the first complete line inside the retained suffix, matching + /// retail's newline preference. /// /// public const int MaxTranscriptCharacters = 0x2710; @@ -119,6 +120,14 @@ internal static class ChatTranscriptRenderer IReadOnlyList detailed, Func? accept, int budget = MaxTranscriptCharacters) + => FindBudgetStart(detailed, accept, budget).LineIndex; + + private readonly record struct BudgetStart(int LineIndex, int CharacterOffset); + + private static BudgetStart FindBudgetStart( + IReadOnlyList detailed, + Func? accept, + int budget = MaxTranscriptCharacters) { long used = 0; for (int i = detailed.Count - 1; i >= 0; i--) @@ -127,11 +136,68 @@ internal static class ChatTranscriptRenderer continue; // +1 for the newline retail stores between lines. - used += detailed[i].Text.Length + 1; - if (used > budget) - return i + 1; + long cost = detailed[i].Text.Length + 1L; + if (used + cost <= budget) + { + used += cost; + continue; + } + + int available = (int)Math.Max(0L, budget - used - 1L); + if (available > 0) + { + string text = detailed[i].Text; + int minimumOffset = Math.Max(0, text.Length - available); + int offset = FirstCharacterAfterLineBreak(text, minimumOffset); + if (offset < text.Length) + return new BudgetStart(i, offset); + + // A single newest unbroken message must still remain visible; + // dropping it wholesale is what made large @acecommands + // replies render as an empty transcript. + if (used == 0 && text.Length > 0) + return new BudgetStart(i, minimumOffset); + } + return new BudgetStart(i + 1, 0); } - return 0; + return new BudgetStart(0, 0); + } + + private static int FirstCharacterAfterLineBreak(string text, int start) + { + for (int i = Math.Clamp(start, 0, text.Length); i < text.Length; i++) + { + if (text[i] is not ('\r' or '\n')) + continue; + if (text[i] == '\r' && i + 1 < text.Length && text[i + 1] == '\n') + i++; + return i + 1; + } + return text.Length; + } + + private static FormattedLine SliceLine(FormattedLine line, int offset) + { + if (offset <= 0) + return line; + + string text = line.Text[offset..]; + if (line.Spans is not { Count: > 0 } spans) + return line with { Text = text }; + + var sliced = new List(); + int at = 0; + foreach (ChatTextSpan span in spans) + { + int end = at + span.Text.Length; + if (end > offset) + { + int from = Math.Max(offset, at) - at; + sliced.Add(span with { Text = span.Text[from..] }); + } + at = end; + } + return line with { Text = text, Spans = sliced }; } /// @@ -257,12 +323,14 @@ internal static class ChatTranscriptRenderer // (defaultColor), matching retail's DoFontReset — not the color table's // unrelated index-0x00 slot. Vector4 currentColor = defaultColor; - int firstLine = FirstLineWithinBudget(detailed, accept); - for (int lineIndex = firstLine; lineIndex < detailed.Count; lineIndex++) + BudgetStart start = FindBudgetStart(detailed, accept); + for (int lineIndex = start.LineIndex; lineIndex < detailed.Count; lineIndex++) { FormattedLine d = detailed[lineIndex]; if (accept is not null && !accept(d.LogTextType)) continue; + if (lineIndex == start.LineIndex && start.CharacterOffset > 0) + d = SliceLine(d, start.CharacterOffset); if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved)) currentColor = resolved; // Wrapping can DROP the space it broke on, so a fragment is not diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index ff31b824..cc465bc2 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -5,6 +5,7 @@ using AcDream.App.Rendering; using AcDream.App.UI; using AcDream.Core.Chat; using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Input; using AcDream.UI.Abstractions.Panels.Chat; namespace AcDream.App.UI.Layout; @@ -1029,6 +1030,44 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta FindRootOf(Input)?.SetKeyboardFocus(Input); } + /// + /// Retail EnterChatMode: enter write mode and select the complete + /// existing entry so the next typed character replaces it. + /// + internal void EnterChatMode(KeyChord? physicalChord = null) + { + UiRoot? root = FindRootOf(Input); + root?.SetKeyboardFocus(Input); + if (physicalChord is { Device: 0 } chord) + root?.SuppressPhysicalKeyUntilRelease(chord.Key); + Input.SelectAllText(); + } + + /// Retail ToggleChatEntry: toggle write-mode focus. + internal void ToggleChatEntry(KeyChord? physicalChord = null) + { + UiRoot? root = FindRootOf(Input); + if (root is null) + return; + root.SetKeyboardFocus(ReferenceEquals(root.KeyboardFocus, Input) ? null : Input); + if (physicalChord is { Device: 0 } chord) + root.SuppressPhysicalKeyUntilRelease(chord.Key); + } + + /// Retail command/alias hotkey: begin an ordinary slash command. + internal void StartCommand() + { + Input.SetText("/"); + FindRootOf(Input)?.SetKeyboardFocus(Input); + } + + /// Retail reply keys are silent when their independent target is empty. + internal void StartReply(string? name) + { + if (!string.IsNullOrEmpty(name)) + StartTell(name); + } + private static UiRoot? FindRootOf(UiElement element) { for (UiElement? at = element; at is not null; at = at.Parent) diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index c81113b5..5091ec3f 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -115,7 +115,7 @@ public static class DatWidgetFactory // pre-OP2 UiDatElement fallback (media drawn, ClickThrough=true, state // propagation) because nothing ever activates it. 5 => new UiTemplateListBox(info, resolve, info.TemplateList, info.ScrollbarElementId), - 6 => new UiMenu(), // UIElement_Menu (reg :120163) + 6 => BuildMenu(info, resolve, elementFont, fontResolve), // UIElement_Menu (reg :120163) 7 => BuildMeter(info, resolve, elementFont, stringResolve), // UIElement_Meter // UIElement_Panel (Type 8) — retail's tab-strip host (dat property 0x2E; // research doc §1.3/§10.1). OP2 rework (docs/research/2026-08-11-op2- @@ -133,6 +133,7 @@ public static class DatWidgetFactory 11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar (reg :124137) 12 => BuildText(info, resolve, elementFont, stringResolve), // UIElement_Text 0x13 => new UiDialogRoot(), // ConfirmationDialog + 0x14 => new UiDialogRoot(), // ConfirmationMenuDialog 0x15 => new UiDialogRoot(), // ConfirmationTextInputDialog 0x17 => new UiDialogRoot(), // MessageDialog 0x19 => new UiDialogRoot(), // WaitDialog (catalog root 0x31 — OP8 #396) @@ -163,7 +164,7 @@ public static class DatWidgetFactory // ArrowCapClosedSprite doc comment). Built blank, exactly like the Type-6 // case above — a page controller wires its sprites/items the same way // ChatWindowController wires the channel menu. - 0x10000038u => new UiMenu(), + 0x10000038u => BuildMenu(info, resolve, elementFont, fontResolve), // UIOption_CheckboxBitfield64 (Type 0x10000044): the Chat tab's per-window // text-filter block. OP2 rework (docs/research/2026-08-11-op2-review- // mechanism.md MUST-FIX 4): the authored template (0x10000520) DOES author @@ -209,6 +210,42 @@ public static class DatWidgetFactory return e; } + /// + /// Retail's generic menu class supplies its standard face/popup chrome even + /// when no game-specific controller customizes it. This matters for catalog + /// dialogs such as ConfirmationMenu: their Type-6 leaf is the whole control, + /// and intentionally absorbs the + /// authored label child. Existing chat/vendor/options controllers overwrite + /// these defaults with their own probed variants. + /// + private static UiMenu BuildMenu( + ElementInfo info, + Func resolve, + UiDatFont? elementFont, + Func? fontResolve) + { + ElementInfo? label = info.Children.FirstOrDefault( + static child => child.Type == 12u); + UiDatFont? labelFont = label is { FontDid: not 0u } && fontResolve is not null + ? fontResolve(label.FontDid) ?? elementFont + : elementFont; + var menu = new UiMenu + { + SpriteResolve = resolve, + DatFont = labelFont, + ButtonDatFont = labelFont, + NormalSprite = 0x06004D65u, + PressedSprite = 0x06004D66u, + PopupBgSprite = 0x0600124Cu, + ItemNormalSprite = 0x0600124Eu, + ItemHighlightSprite = 0x0600124Du, + ButtonTextCentered = label?.HJustify == HJustify.Center, + }; + if (label?.FontColor is { } color) + menu.TextColor = color; + return menu; + } + /// /// Bind inherited scrollbar media structurally. Property 0x77 names the /// increment button and 0x78 the decrement button; retail diff --git a/src/AcDream.App/UI/Layout/ExternalContainerController.cs b/src/AcDream.App/UI/Layout/ExternalContainerController.cs index 75d9a9f5..48e92883 100644 --- a/src/AcDream.App/UI/Layout/ExternalContainerController.cs +++ b/src/AcDream.App/UI/Layout/ExternalContainerController.cs @@ -43,6 +43,7 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine private readonly UiItemList _contentsList; private uint _openContainer; + private PendingBackpackPlacement? _pendingPlacement; private bool _closeRequested; private bool _disposed; @@ -115,6 +116,9 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine _objects.Cleared += OnObjectsCleared; _selection.Changed += OnSelectionChanged; _itemInteraction.StateChanged += OnInteractionStateChanged; + _itemInteraction.PendingBackpackPlacementRequested += OnPendingPlacementRequested; + _itemInteraction.PendingBackpackPlacementCancelled += OnPendingPlacementCancelled; + _itemInteraction.PendingBackpackPlacementResolved += OnPendingPlacementResolved; ClearLists(); } @@ -210,13 +214,14 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine UiItemSlot targetCell, ItemDragPayload payload) { + if (payload.SourceKind == ItemDragSource.ShortcutBar) + return ItemDragAcceptance.None; if (!ReferenceEquals(targetList, _contentsList) - || payload.SourceKind == ItemDragSource.ShortcutBar - || payload.ObjId == 0u - || _openContainer == 0u - || payload.ObjId == _openContainer) + || _openContainer == 0u) return ItemDragAcceptance.Reject; - return ItemDragAcceptance.Accept; + return EvaluateDrop(payload.ObjId) == InventoryContainerPlacementRejection.None + ? ItemDragAcceptance.Accept + : ItemDragAcceptance.Reject; } public void HandleDropRelease( @@ -224,8 +229,19 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine UiItemSlot targetCell, ItemDragPayload payload) { - if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept) + InventoryContainerPlacementRejection legality = EvaluateDrop(payload.ObjId); + if (legality != InventoryContainerPlacementRejection.None) + { + if (InventoryContainerPlacementPolicy.ComposeClientLocal( + legality, + _objects.Get(payload.ObjId), + _objects.Get(_openContainer), + playerId: 0u) is { } refusal) + { + _itemInteraction.ReportClientLocal(refusal); + } return; + } if (!_itemInteraction.EnsureInventoryRequestReady()) return; if (_objects.Get(payload.ObjId) is not { } item) @@ -246,17 +262,30 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine InventoryRequestKind kind = amount < fullStack ? InventoryRequestKind.SplitToContainer : InventoryRequestKind.PutInContainer; - _itemInteraction.TryDispatchInventoryRequest( - kind, - item.ObjectId, - () => - { - if (amount < fullStack) + if (amount < fullStack) + { + _itemInteraction.TryDispatchInventoryRequest( + kind, + item.ObjectId, + () => + { _sendSplitToContainer(item.ObjectId, _openContainer, (uint)placement, amount); - else + return true; + }); + } + else + { + _itemInteraction.TryDispatchPendingBackpackPlacement( + item.ObjectId, + _openContainer, + placement, + kind, + () => + { _sendPutItemInContainer(item.ObjectId, _openContainer, placement); - return true; - }); + return true; + }); + } } private void OnExternalContainerChanged(ExternalContainerTransition transition) @@ -314,10 +343,29 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine AddContainerCell(guid); } + var visibleContents = new List(); foreach (uint guid in _objects.GetContents(_openContainer)) { if (!IsContainer(_objects.Get(guid))) - AddContentsCell(guid); + visibleContents.Add(guid); + } + if (_pendingPlacement is { } pending + && pending.ContainerId == _openContainer + && _objects.Get(pending.ItemId) is { } pendingItem + && !IsContainer(pendingItem)) + { + visibleContents.Remove(pending.ItemId); + visibleContents.Insert( + Math.Clamp(pending.Placement, 0, visibleContents.Count), + pending.ItemId); + } + foreach (uint guid in visibleContents) + { + bool waiting = _itemInteraction.IsPendingInventorySource(guid) + || _pendingPlacement is { } projection + && projection.ContainerId == _openContainer + && projection.ItemId == guid; + AddContentsCell(guid, waiting); } ApplyIndicators(); } @@ -334,15 +382,15 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine private void AddContainerCell(uint guid) { UiItemSlot cell = CreateCell(_containerList, guid, ItemDragSource.Ground); - cell.Clicked = () => OpenNestedContainer(guid); SetCapacity(cell, guid); _containerList.AddItem(cell); } - private void AddContentsCell(uint guid) + private void AddContentsCell(uint guid, bool waiting = false) { UiItemSlot cell = CreateCell(_contentsList, guid, ItemDragSource.Ground); cell.DoubleClicked = () => _itemInteraction.ActivateItem(guid); + cell.SetWaitingState(waiting); cell.DragAcceptSprite = 0x060011F9u; cell.DragRejectSprite = 0x060011F8u; _contentsList.AddItem(cell); @@ -387,7 +435,10 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine { if (_itemInteraction.OfferPrimaryClick(guid) != ItemPrimaryClickResult.NotActive) return true; - Select(guid); + if (IsContainer(_objects.Get(guid)) && guid != _state.CurrentContainerId) + OpenNestedContainer(guid); + else + Select(guid); return false; } @@ -406,7 +457,8 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine bool pendingSource = _itemInteraction.IsPendingSource(cell.ItemId); cell.Selected = cell.ItemId != 0u && cell.ItemId == _selection.SelectedObjectId - && !pendingSource; + && !pendingSource + && !_itemInteraction.IsPendingInventorySource(cell.ItemId); cell.IsOpenContainer = cell.ItemId != 0u && cell.ItemId == _openContainer; } } @@ -486,7 +538,48 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine } private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators(); - private void OnInteractionStateChanged() => ApplyIndicators(); + private void OnInteractionStateChanged() + { + if (_window.IsVisible) + Populate(); + else + ApplyIndicators(); + } + + private void OnPendingPlacementRequested(PendingBackpackPlacement pending) + { + if (pending.ContainerId != _openContainer) + return; + _pendingPlacement = pending; + if (_window.IsVisible) + Populate(); + } + + private void OnPendingPlacementCancelled(PendingBackpackPlacement pending) + => ResolvePendingPlacement(pending); + + private void OnPendingPlacementResolved(PendingBackpackPlacement pending) + => ResolvePendingPlacement(pending); + + private void ResolvePendingPlacement(PendingBackpackPlacement pending) + { + if (_pendingPlacement is not { } current || current.Token != pending.Token) + return; + _pendingPlacement = null; + if (_window.IsVisible) + Populate(); + } + + private InventoryContainerPlacementRejection EvaluateDrop(uint itemId) + { + if (_objects.Get(itemId) is { } source && IsContainer(source)) + return InventoryContainerPlacementRejection.ContainerCapacityFull; + return InventoryContainerPlacementPolicy.Evaluate( + _objects, + itemId, + _openContainer, + playerId: 0u); + } private static bool IsContainer(ClientObject? item) => item is not null @@ -573,6 +666,9 @@ public sealed class ExternalContainerController : IItemListDragHandler, IRetaine _objects.Cleared -= OnObjectsCleared; _selection.Changed -= OnSelectionChanged; _itemInteraction.StateChanged -= OnInteractionStateChanged; + _itemInteraction.PendingBackpackPlacementRequested -= OnPendingPlacementRequested; + _itemInteraction.PendingBackpackPlacementCancelled -= OnPendingPlacementCancelled; + _itemInteraction.PendingBackpackPlacementResolved -= OnPendingPlacementResolved; _topContainer.PrimaryItemPressed = null; _containerList.PrimaryItemPressed = null; _contentsList.PrimaryItemPressed = null; diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 7fb41022..e03a5fc5 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -116,6 +116,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo _itemInteraction = itemInteraction; _stackSplitQuantity = stackSplitQuantity; _selection = selection ?? throw new ArgumentNullException(nameof(selection)); + if (_itemInteraction is not null) + _itemInteraction.MergeAttempted += OnMergeAttempted; WindowChromeController.BindCloseButton(layout, onClose); @@ -299,14 +301,13 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo if (containerId == EffectiveOpen() || containerId == _playerGuid()) Populate(); } - private void OnInteractionStateChanged() => ApplyIndicators(); + private void OnInteractionStateChanged() => Populate(); private void OnPendingBackpackPlacementRequested(PendingBackpackPlacement pending) { if (_pendingListPlacement is not null || pending.ItemId == 0u - || pending.ContainerId != EffectiveOpen() - || _objects.Get(pending.ItemId) is not { } item - || IsBag(item)) + || pending.ContainerId == 0u + || _objects.Get(pending.ItemId) is null) { return; } @@ -375,12 +376,33 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo // Side-bag column: ALWAYS the player's bags (constant across container switches; only the // open/selected indicators move). Equipped items never appear here. + var visibleBags = new List(); foreach (var guid in _objects.GetContents(p)) { var item = _objects.Get(guid); if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue; bool isBag = IsBag(item); - if (isBag) AddCell(_containerList, guid, isContainer: true); + if (isBag) visibleBags.Add(guid); + } + + PendingListPlacement? pending = _pendingListPlacement; + if (pending is { } bagProjection + && bagProjection.ContainerId == p + && _objects.Get(bagProjection.ItemId) is { } pendingBag + && IsBag(pendingBag)) + { + visibleBags.Remove(bagProjection.ItemId); + int index = Math.Clamp(bagProjection.Placement, 0, visibleBags.Count); + visibleBags.Insert(index, bagProjection.ItemId); + } + + foreach (uint guid in visibleBags) + { + bool waiting = IsWaitingSource(guid) + || pending is { } waitingBagProjection + && waitingBagProjection.ContainerId == p + && waitingBagProjection.ItemId == guid; + AddCell(_containerList, guid, isContainer: true, waiting); } // Contents grid: the OPEN container's loose items. (Bags live in the column; a side bag has @@ -394,20 +416,20 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo if (!isBag) visibleContents.Add(guid); } - PendingListPlacement? pending = _pendingListPlacement; if (pending is { } projection && projection.ContainerId == open - && !visibleContents.Contains(projection.ItemId) && _objects.Get(projection.ItemId) is { } pendingItem && !IsBag(pendingItem)) { + visibleContents.Remove(projection.ItemId); int index = Math.Clamp(projection.Placement, 0, visibleContents.Count); visibleContents.Insert(index, projection.ItemId); } foreach (uint guid in visibleContents) { - bool waiting = pending is { } waitingProjection + bool waiting = IsWaitingSource(guid) + || pending is { } waitingProjection && waitingProjection.ContainerId == open && waitingProjection.ItemId == guid; AddCell(_contentsGrid, guid, isContainer: false, waiting); @@ -455,8 +477,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo dragIconTexture: _dragIconIds?.Invoke( ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u) ?? 0u); main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u; + main.SetWaitingState(IsWaitingSource(p)); main.Clicked = () => OpenContainer(p); - main.DoubleClicked = () => _itemInteraction?.ActivateItem(p); SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity) _topContainer.AddItem(main); } @@ -474,6 +496,21 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo || item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0; + private int CountLooseContents(uint containerId) + { + int count = 0; + foreach (uint guid in _objects.GetContents(containerId)) + { + if (_objects.Get(guid) is { } item + && item.CurrentlyEquippedLocation == EquipMask.None + && !IsBag(item)) + { + count++; + } + } + return count; + } + private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid(); /// The owned destination retail PlaceInBackpack currently uses. @@ -499,12 +536,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo cell.SetWaitingState(waiting); cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list) ConfigureDropFeedback(list, cell); - cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid); if (isContainer) { cell.Clicked = () => OpenContainer(guid); SetCapacityBar(cell, guid); } + else + { + cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid); + } list.AddItem(cell); } @@ -513,7 +553,10 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo if (_itemInteraction?.OfferPrimaryClick(guid) is not null and not ItemPrimaryClickResult.NotActive) return true; - SelectItem(guid); + if (_objects.Get(guid) is { } item && IsBag(item)) + OpenContainer(guid); + else + SelectItem(guid); return false; } @@ -522,7 +565,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo if (_itemInteraction?.OfferSelfPrimaryClick() is not null and not ItemPrimaryClickResult.NotActive) return true; - SelectItem(guid); + OpenContainer(guid); return false; } @@ -556,11 +599,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo { int cap = _objects.Get(containerGuid)?.ItemsCapacity ?? 0; if (cap <= 0) { cell.CapacityFill = -1f; return; } - int n = _objects.GetContents(containerGuid).Count; + // Player contents contain two independent retail lists: loose items + // and side packs. ItemsCapacity applies only to the former; counting + // packs here made a main pack stay visually/full logically rejected + // even after the player freed an item slot. + int n = CountLooseContents(containerGuid); cell.CapacityFill = Math.Clamp(n / (float)cap, 0f, 1f); } - // ── IItemListDragHandler (B-Drag) — drop an item to move it (optimistic + wire) ────────────── + // ── IItemListDragHandler (B-Drag) — request first; server owns placement ──────────────────── /// Retail ItemList_BeginDrag selects an unselected item before enabling its waiting /// mesh. Inventory items do not lift-remove (unlike the toolbar): the item stays in its slot /// until the server confirms the eventual drop. @@ -583,35 +630,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo // remove-on-lift stands. if (payload.SourceKind == ItemDragSource.ShortcutBar) return ItemDragAcceptance.None; - if (payload.ObjId == 0) - return ItemDragAcceptance.Reject; - bool sourceIsBag = _objects.Get(payload.ObjId) is { } source && IsBag(source); - if (targetList == _contentsGrid) - return sourceIsBag - ? ItemDragAcceptance.Reject - : ItemDragAcceptance.Accept; - if (targetList == _containerList || targetList == _topContainer) - { - // UIElement_ItemList::ItemList_DragOver @0x004E3400 checks the - // dragged object's container flag before interpreting this list. - // A container drag addresses the player's contained-container - // list itself; an empty authored slot is therefore a valid pack - // destination rather than "no target". - if (sourceIsBag) - return targetCell.ItemId == payload.ObjId - ? ItemDragAcceptance.Reject - : ItemDragAcceptance.Accept; - if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId) - return ItemDragAcceptance.Reject; - return IsContainerFull(targetCell.ItemId) - ? ItemDragAcceptance.Reject - : ItemDragAcceptance.Accept; - } - return ItemDragAcceptance.Reject; + return EvaluateDrop(targetList, targetCell, payload.ObjId, out _, out _) + == InventoryContainerPlacementRejection.None + ? ItemDragAcceptance.Accept + : ItemDragAcceptance.Reject; } /// Resolve the destination and either split or move the stack. A partial split waits - /// for the server-created object's guid; a whole move remains optimistic. Retail: + /// for the server-created object's guid; a whole move displays only the + /// destination list's waiting projection until the server responds. Retail: /// ItemHolder::AttemptToPlaceInContainer @ 0x00588140. public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) { @@ -627,8 +654,24 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo // DropReleased is still delivered to the list after a reject overlay; // pin the release to the same retail policy instead of relying on the // advisory color alone. - if (OnDragOver(targetList, targetCell, payload) != ItemDragAcceptance.Accept) + InventoryContainerPlacementRejection legality = EvaluateDrop( + targetList, + targetCell, + item, + out _, + out uint legalityDestination); + if (legality != InventoryContainerPlacementRejection.None) + { + if (InventoryContainerPlacementPolicy.ComposeClientLocal( + legality, + _objects.Get(item), + _objects.Get(legalityDestination), + _playerGuid()) is { } refusal) + { + _itemInteraction?.ReportClientLocal(refusal); + } return; + } // UIElement_ItemList::AcceptDragObject @ 0x004E4250 rejects every // release while m_pendingItem exists, before merge, split, or ordinary @@ -662,7 +705,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo container = EffectiveOpen(); placement = targetCell.ItemId != 0 ? targetCell.SlotIndex // insert-before = the target's GRID INDEX (gapless), not its raw ContainerSlot - : _objects.GetContents(container).Count; // first empty = append + : CountLooseContents(container); // first empty = append after visible loose items } else if (targetList == _containerList || targetList == _topContainer) { @@ -697,79 +740,65 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo { // UIAttemptSplitToContainer leaves the source stack where it is. ACE will // publish the reduced source plus a newly-guided destination stack. - DispatchInventoryRequest( - InventoryRequestKind.SplitToContainer, - item, - () => - { - if (_sendStackableSplitToContainer is null) - return false; - _sendStackableSplitToContainer( - item, - container, - (uint)placement, - splitSize); - return true; - }); + if (_itemInteraction is not null) + { + _itemInteraction.TrySplitToContainer( + item, + container, + (uint)placement, + splitSize); + } + else + { + DispatchInventoryRequest( + InventoryRequestKind.SplitToContainer, + item, + () => + { + if (_sendStackableSplitToContainer is null) + return false; + _sendStackableSplitToContainer( + item, + container, + (uint)placement, + splitSize); + return true; + }); + } return; } } - // External-container contents retain canonical ownership while the request - // is in flight, but retail immediately inserts an m_pendingItem copy into - // the chosen destination slot and ghosts it. The server move/failure notice - // resolves that visual projection. UIElement_ItemList::HandleDropRelease - // @ 0x004E4790; ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680. - if (payload.SourceKind == ItemDragSource.Ground) + // Canonical ownership never changes on request. Retail immediately + // publishes the destination ItemList's m_pendingItem projection and + // resolves it from the server move/failure response. + if (_itemInteraction is not null) { - if (_itemInteraction is not null) - { - if (!_itemInteraction.TryDispatchPendingBackpackPlacement( - item, - container, - placement, - InventoryRequestKind.Pickup, - () => - { - if (_sendPutItemInContainer is null) - return false; - _sendPutItemInContainer(item, container, placement); - return true; - })) - { - return; - } - return; - } - else - { - if (_pendingListPlacement is not null) - return; - _pendingListPlacement = new PendingListPlacement(0u, item, container, placement); - Populate(); - } - } - else - { - if (_itemInteraction is not null) - { - DispatchInventoryRequest( - InventoryRequestKind.PutInContainer, + InventoryRequestKind kind = payload.SourceKind == ItemDragSource.Ground + ? InventoryRequestKind.Pickup + : InventoryRequestKind.PutInContainer; + if (!_itemInteraction.TryDispatchPendingBackpackPlacement( item, + container, + placement, + kind, () => { - if (_sendPutItemInContainer is null - || !_objects.MoveItemOptimistic(item, container, placement)) - { + if (_sendPutItemInContainer is null) return false; - } _sendPutItemInContainer(item, container, placement); return true; - }); + })) + { return; } - _objects.MoveItemOptimistic(item, container, placement); + return; } + + if (_pendingListPlacement is not null) + return; + _pendingListPlacement = new PendingListPlacement(0u, item, container, placement); + Populate(); _sendPutItemInContainer?.Invoke(item, container, placement); } @@ -832,9 +861,57 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo { int cap = _objects.Get(container)?.ItemsCapacity ?? 0; if (cap <= 0) return false; - return _objects.GetContents(container).Count >= cap; + return CountLooseContents(container) >= cap; } + private void OnMergeAttempted(uint sourceId, uint targetId) + { + _notifyMergeAttempt?.Invoke(sourceId, targetId); + _selection.Select(targetId, SelectionChangeSource.Inventory); + } + + private InventoryContainerPlacementRejection EvaluateDrop( + UiItemList targetList, + UiItemSlot targetCell, + uint itemId, + out bool sourceIsBag, + out uint destinationId) + { + sourceIsBag = _objects.Get(itemId) is { } source && IsBag(source); + destinationId = 0u; + if (itemId == 0u) + return InventoryContainerPlacementRejection.InvalidItem; + + if (ReferenceEquals(targetList, _contentsGrid)) + { + destinationId = EffectiveOpen(); + // Carried containers belong to the authored container selector, + // never the loose-item grid, even when both address the player. + if (sourceIsBag) + return InventoryContainerPlacementRejection.ContainerCapacityFull; + } + else if (ReferenceEquals(targetList, _containerList) + || ReferenceEquals(targetList, _topContainer)) + { + destinationId = sourceIsBag ? _playerGuid() : targetCell.ItemId; + if (!sourceIsBag && (targetCell.ItemId == 0u || targetCell.ItemId == itemId)) + return InventoryContainerPlacementRejection.InvalidDestination; + } + else + { + return InventoryContainerPlacementRejection.InvalidDestination; + } + + return InventoryContainerPlacementPolicy.Evaluate( + _objects, + itemId, + destinationId, + _playerGuid()); + } + + private bool IsWaitingSource(uint itemGuid) + => _itemInteraction?.IsPendingInventorySource(itemGuid) == true; + /// Select an item (panel-wide green square) without changing the open container or /// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0). private void SelectItem(uint guid) @@ -895,7 +972,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo { var cell = list.GetItem(i); if (cell is null) continue; - bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true; + bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true + || IsWaitingSource(cell.ItemId); cell.Selected = cell.ItemId != 0 && cell.ItemId == _selection.SelectedObjectId && !pendingTargetSource; @@ -1012,6 +1090,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo } if (_itemInteraction is not null) { + _itemInteraction.MergeAttempted -= OnMergeAttempted; _itemInteraction.StateChanged -= OnInteractionStateChanged; _itemInteraction.PendingBackpackPlacementRequested -= OnPendingBackpackPlacementRequested; _itemInteraction.PendingBackpackPlacementCancelled -= OnPendingBackpackPlacementCancelled; diff --git a/src/AcDream.App/UI/Layout/JournalPanelController.cs b/src/AcDream.App/UI/Layout/JournalPanelController.cs index 141a1485..f0846720 100644 --- a/src/AcDream.App/UI/Layout/JournalPanelController.cs +++ b/src/AcDream.App/UI/Layout/JournalPanelController.cs @@ -180,6 +180,9 @@ public sealed class JournalPanelController : IRetainedPanelController /// Switches to the notes tab — what opening a page from the index does. public void ShowNotes() => _tabPanel.SwitchTo(NotesPageId); + /// Switches to the authored journal index tab. + public void ShowPageList() => _tabPanel.SwitchTo(PageListPageId); + /// /// Completes construction. The index needs a callback that switches tabs, /// which needs the panel — so it is attached rather than constructed. diff --git a/src/AcDream.App/UI/Layout/KeyboardConfigController.cs b/src/AcDream.App/UI/Layout/KeyboardConfigController.cs index c1c67d33..a04b4739 100644 --- a/src/AcDream.App/UI/Layout/KeyboardConfigController.cs +++ b/src/AcDream.App/UI/Layout/KeyboardConfigController.cs @@ -66,19 +66,17 @@ namespace AcDream.App.UI.Layout; /// /// /// Row identity and binding storage (D4). Every row's identity is the DAT -/// pair (InputMapId, ActionId) — retail's own row key. Where -/// resolves that pair to an acdream -/// (research: roughly half of the DAT's 306 rows — see -/// that table's class doc for the full accounting), the row's bindings ARE +/// pair (InputMapId, ActionId) — retail's own row key. The installed EoR +/// ActionMap's 306 pairs each resolve to one distinct acdream +/// ; the row's bindings ARE /// 's bindings for that action: a rebind here takes /// effect immediately for live gameplay dispatch through the SAME -/// every other input path uses, and persists to -/// keybinds.json exactly like any other rebind (D4 — no separate -/// .keymap file format). Where no exists yet -/// (mostly Emotes and CharacterSettings — see the identity table's class doc), -/// the row is still fully rendered, bindable, conflict-checked, and persisted -/// (/), -/// it just has no live gameplay consumer yet (register row). +/// every other input path uses. Retail Load File / +/// Save As exchange the original PFile *.keymap format; acdream also writes +/// keybinds.json as its portable mirror for host-only commands. The +/// nullable/unmapped delegates remain solely +/// so an unknown future-DAT row stays visible and round-trippable instead of +/// crashing an older client. /// /// /// @@ -90,8 +88,8 @@ namespace AcDream.App.UI.Layout; /// against every multi-chord action in KeyBindings.RetailDefaults(): /// walk-mode's Hold, the three melee/missile/magic combat scopes, ...), so this /// row captures that pair ONCE at build time (from the first live binding, or -/// / if the action -/// starts wholly unbound) and reapplies it to every chord this row ever writes — +/// the retail identity table if the action starts wholly unbound) and reapplies +/// it to every chord this row ever writes — /// on a live rebind, on Cancel/Revert (RestoreSavedValue), and on Defaults /// (RestoreDefaultValue, which restores DAT-sourced KEYS only; Activation/ /// Scope are retail-side properties of the ACTION, not of which physical key @@ -113,9 +111,8 @@ namespace AcDream.App.UI.Layout; /// ANY conflicting target is non-user-bindable). This port's non-user-bindable /// analogue is a chord already bound to an acdream-only action with no /// row at all (Ctrl+M mute, the debug -/// F-keys, ...) — refused via -/// exactly like retail's distinct OpenCantOverwriteBindingDialog, with no -/// dialog (a hard stop, matching the DAT-verified refusal string). A genuine +/// F-keys, ...) — refused through retail's type-3 +/// OpenCantOverwriteBindingDialog with the exact DAT template. A genuine /// cross-row conflict collects EVERY conflicting row (not just the first) and /// opens a real confirm dialog through — /// retail's OpenOverwriteBindingDialog(&conflicts) — BEFORE reassigning; @@ -123,15 +120,10 @@ namespace AcDream.App.UI.Layout; /// /// /// -/// Caption dimming (AD-78, user-directed, 2026-08-11, gate 2). A row -/// whose is null (AP-203's store-only -/// set — mostly Emotes and CharacterSettings, plus every non-user-bindable -/// InputMap this screen renders) dims its synthesized caption via -/// in -/// . The row stays fully rendered, bindable, -/// conflict-checked, and persisted (per the paragraph above) — only the -/// caption color changes, so the dim is a visual "no live gameplay consumer -/// yet" marker, not a functional restriction. +/// Campaign KB maps all 306 installed EoR rows to distinct live actions, so +/// every authored command is enabled and uses the normal caption color. The +/// nullable defensive path remains only to make an unknown future DAT row +/// visible without crashing an older client. /// /// public sealed class KeyboardConfigController @@ -204,8 +196,14 @@ public sealed class KeyboardConfigController Action> BeginCapture, Action Save, Action Toggle, - Action DisplaySystemMessage, - string NonBindableRefusalText, + // Resolves one of retail's ID_ActionKeyMap_* templates from string-table + // enum 0x10000004 (installed DID 0x23000004). Null means the retail text + // is unavailable; callers then leave the operation inert instead of + // inventing UI prose. + Func, string?> ResolveTemplate, + // Retail OpenCantOverwriteBindingDialog is a type-3 priority message on + // keyboard queue 0x10000001, not a scrolling-chat/system message. + Action ShowMessage, // M3 (2026-08-11 review): retail's OpenOverwriteBindingDialog — confirm // BEFORE reassigning a chord already bound to another row on this screen. // message is pre-composed (real row labels, no invented retail text); @@ -221,25 +219,36 @@ public sealed class KeyboardConfigController // or ESC). Null keeps the pre-dialog capture behavior for hosts with // no dialog factory (unit fixtures). Func? OpenCaptureInstructions = null, - Action? CloseCaptureInstructions = null); + Action? CloseCaptureInstructions = null, + // Retail gmKeyboardUI's Load File / Save As workflows. Each opener + // invokes its callback only after a successful profile operation. + Func? CurrentKeymapFilename = null, + Action? OpenLoadKeymap = null, + Action? OpenSaveKeymap = null); public OptionPage Page { get; } = new(); public IReadOnlyList Rows => _rows; private readonly List _rows = new(); private readonly Dictionary<(uint LayoutId, uint ElementId), UiDatFont?> _templateFontCache = new(); + private RetailActionMapSnapshot? _snapshot; private Bindings? _bindings; private Func _describe = DescribeChord; private Func? _resolveTemplateFont; + private static readonly uint ActionVariable = DatStringResolver.ComputeHash("ACTION"); + private static readonly uint BindingsVariable = DatStringResolver.ComputeHash("BINDINGS"); + private static readonly uint KeyVariable = DatStringResolver.ComputeHash("KEY"); + private static readonly uint LabelVariable = DatStringResolver.ComputeHash("LABEL"); + private static readonly uint ValueVariable = DatStringResolver.ComputeHash("VALUE"); + private KeyboardConfigController() { } /// /// Builds every header + row across all six pages from /// , wires each row's key buttons to modal /// capture / right-click erase, and wires the screen's own six buttons - /// (Defaults/Revert/OK/Cancel; Load/Save File are INERT — D4, no - /// .keymap interchange). Returns null if the layout's window root + /// (Load File/Save As/Defaults/Revert/OK/Cancel). Returns null if the layout's window root /// did not import (a missing/malformed LayoutDesc). /// public static KeyboardConfigController? Bind( @@ -266,6 +275,7 @@ public sealed class KeyboardConfigController var controller = new KeyboardConfigController { + _snapshot = snapshot, _bindings = bindings, _resolveTemplateFont = resolveTemplateFont, // OP8 re-gate (2026-08-14): key-button captions through retail's @@ -397,11 +407,9 @@ public sealed class KeyboardConfigController // The row's own caption — synthesized, composed beside the authored key // buttons (UiText is sealed; see class doc). Occupies the "Command" column - // (x=0..270, matching the authored column headers). AD-78 (user-directed, - // 2026-08-11, gate 2): an unmapped row (MappedAction null — no live - // InputDispatcher consumer, AP-203) dims its caption; the key buttons - // themselves stay fully interactive (bindable/persisted/conflict-checked, - // see class doc). + // (x=0..270, matching the authored column headers). All 306 EoR rows + // are mapped; the dim color is only a forward-compatible signal for + // a row introduced by a different DAT revision. var captionText = new UiText { Left = 0f, @@ -423,39 +431,41 @@ public sealed class KeyboardConfigController }; if (label is not null) captionText.LinesProvider = () => new[] { new UiText.Line(label, captionText.DefaultColor) }; + // UIOption_ActionKeyMap::SetTooltip applies the ActionMap row's own + // tooltip to the row, while Refresh replaces each key button's tooltip + // with the dedicated existing/new-binding templates below. + captionText.AuthoredTooltipText = tooltip; built.AddChild(captionText); // M1: capture this row's live Activation/Scope ONCE, from the first // existing binding for the action (every multi-chord action in // KeyBindings.RetailDefaults() shares one Activation/Scope pair across // all its bindings — see class doc). Falls back to the Binding record's - // own defaults (Press/Game) only when the action starts wholly unbound. + // retail action-identity metadata when the action starts wholly unbound. IReadOnlyList liveBindings = mapped ? bindings.CurrentForAction(action) : Array.Empty(); (ActivationType Activation, InputScope Scope) template = liveBindings.Count > 0 ? (liveBindings[0].Activation, liveBindings[0].Scope) - : (ActivationType.Press, InputScope.Game); + : ( + RetailActionIdentityTable.ActivationFor(row.InputMapId, row.ActionId), + RetailActionIdentityTable.ScopeForInputMap(row.InputMapId)); IReadOnlyList defaults = DatDefaultsToChords(row.DefaultBindings); IReadOnlyList storedUnmapped = mapped ? Array.Empty() : bindings.CurrentForUnmapped((row.InputMapId, row.ActionId)); - // OP8 re-review round 2 (SHOULD-FIX): an unmapped/store-only row with - // no persisted chords displays its DAT DEFAULTS — retail shows the - // authored bindings (the Camera Alternate rows' arrow keys) and a - // blank row misreads as "unbound". Display-only: nothing here feeds - // the InputDispatcher, and the store only gains the defaults if the - // user actually edits the row (the apply closure below). + // An unknown future-DAT row with no persisted chords displays its DAT + // defaults. Installed EoR rows always take the mapped branch. IReadOnlyList initial = mapped ? liveBindings.Select(b => b.Chord).ToArray() : storedUnmapped.Count > 0 ? storedUnmapped : defaults; var model = new ActionKeyMapOptionRow(initial, defaults, apply: value => { - // Interior/padding default(KeyChord) entries (S4 — sparse-slot - // display, see ReplaceSlotValue) are never real bindings; filter - // them out at the write boundary, not at storage time. + // A legacy compatibility store can still contain padding + // default(KeyChord) entries even though the retail production + // editor is dense; never publish those sentinels as bindings. IReadOnlyList real = value.Where(c => c != default).ToArray(); if (mapped) bindings.SetForAction( @@ -474,7 +484,6 @@ public sealed class KeyboardConfigController for (int slot = 0; slot < keyButtons.Count; slot++) { int capturedSlot = slot; - keyButtons[slot].TooltipText = tooltip; keyButtons[slot].OnClick = () => BeginSlotCapture(view, capturedSlot, bindings); keyButtons[slot].OnRightClick = () => EraseSlot(view, capturedSlot); } @@ -516,10 +525,36 @@ public sealed class KeyboardConfigController for (int i = 0; i < view.KeyButtons.Count; i++) { bool bound = i < current.Count && current[i] != default; - view.KeyButtons[i].Label = bound ? _describe(current[i]) : null; + if (!bound) + { + view.KeyButtons[i].Label = null; + view.KeyButtons[i].TooltipText = ResolveTemplate( + "ID_ActionKeyMap_TT_NewBinding", + EmptyTemplateVariables); + continue; + } + + string keyName = _describe(current[i]); + string? buttonLabel = ResolveTemplate( + "ID_ActionKeyMap_ButtonLabel", + new Dictionary { [LabelVariable] = keyName }); + view.KeyButtons[i].Label = buttonLabel; + view.KeyButtons[i].TooltipText = buttonLabel is null + ? null + : ResolveTemplate( + "ID_ActionKeyMap_TT_ExistingBinding", + new Dictionary { [ValueVariable] = buttonLabel }); } } + private static readonly IReadOnlyDictionary EmptyTemplateVariables = + new Dictionary(); + + private string? ResolveTemplate( + string key, + IReadOnlyDictionary variables) => + _bindings?.ResolveTemplate(key, variables); + /// Raw enum spelling — construction-time default until Bind swaps /// in , and that class's own fallback /// for controls outside the DIK table. @@ -548,13 +583,34 @@ public sealed class KeyboardConfigController } } - bindings.BeginCapture(captured => + void ArmCapture() => bindings.BeginCapture(captured => { + if (captured is { } unsupported && IsUnsupportedRetailCapture(unsupported)) + { + // KeyHitHandler @0x004895AF..0x004895DF leaves its input + // handler registered for joystick input and mouse buttons 0/1. + // The authored MapInstructions says the same explicitly. Our + // dispatcher capture is one-shot, so re-arm it while leaving + // the existing wait dialog open. + ArmCapture(); + return; + } + if (instructionsContext != 0u) bindings.CloseCaptureInstructions?.Invoke(instructionsContext); if (captured is not { } chord) return; // Escape — retail cancels silently. + // KeyHitHandler @ 0x0048963B..0x0048964A checks the row's own + // current controls for an EXACT match before it performs any + // cross-map conflict work. Choosing a chord already present in a + // different slot of this row is therefore a silent no-op; it must + // not duplicate the chord into the clicked slot (and must not be + // rejected because an unrelated non-user-bindable action happens + // to share it). + if (view.Model.Current.Contains(chord)) + return; + (ConflictOutcome outcome, List conflictRows) = FindConflicts(chord, exclude: view); switch (outcome) { @@ -564,18 +620,23 @@ public sealed class KeyboardConfigController // conflicting target is non-user-bindable. This port's // analogue: a chord already bound to an acdream-only action // with no DAT row at all (Ctrl+M mute, the debug F-keys, ...) — - // OpenCantOverwriteBindingDialog's ported refusal, no dialog. - bindings.DisplaySystemMessage(bindings.NonBindableRefusalText); + // OpenCantOverwriteBindingDialog @ 0x00489300: exact + // ID_ActionKeyMap_NonUserBindableBinding(KEY) text in a + // type-3 priority message dialog on queue 0x10000001. + string? refusal = bindings.ResolveTemplate( + "ID_ActionKeyMap_NonUserBindableBinding", + new Dictionary { [KeyVariable] = _describe(chord) }); + if (refusal is not null) + bindings.ShowMessage(refusal); return; case ConflictOutcome.Rows: // M3: retail's OpenOverwriteBindingDialog — confirm BEFORE // reassigning (N-way: every conflicting row is named, not just // the first). Only on accept do the losing rows lose the slot. - string names = string.Join(", ", conflictRows.Select(r => r.Label ?? "?")); - string message = - $"'{_describe(chord)}' is already bound to {names}. " - + $"Reassign it to '{view.Label}'?"; + string? message = ComposeOverwriteMessage(chord, conflictRows, bindings); + if (message is null) + return; bindings.ConfirmOverwrite(message, accepted => { if (!accepted) return; @@ -593,13 +654,76 @@ public sealed class KeyboardConfigController return; } }); + + ArmCapture(); + } + + private static bool IsUnsupportedRetailCapture(KeyChord chord) + { + if (chord.Device > 1) + return true; // joystick/unknown device + if (chord.Device == 1 + && (chord.Key == InputDispatcher.MouseButtonToKey(Silk.NET.Input.MouseButton.Left) + || chord.Key == InputDispatcher.MouseButtonToKey(Silk.NET.Input.MouseButton.Right))) + return true; + return !RetailScanCodeMap.TryToFileControl(chord, out _); + } + + private string? ComposeOverwriteMessage( + KeyChord chord, + IReadOnlyList conflicts, + Bindings bindings) + { + string keyName = _describe(chord); + if (conflicts.Count == 1) + { + string? action = conflicts[0].Label; + if (action is null) return null; + return bindings.ResolveTemplate( + "ID_ActionKeyMap_OverwriteExistingBinding", + new Dictionary + { + [KeyVariable] = keyName, + [ActionVariable] = action, + }); + } + + var lines = new List(conflicts.Count); + foreach (RowView conflict in conflicts) + { + if (conflict.Label is null) return null; + string? line = bindings.ResolveTemplate( + "ID_ActionKeyMap_Binding", + new Dictionary + { + [ActionVariable] = conflict.Label, + [KeyVariable] = keyName, + }); + if (line is null) return null; + lines.Add(line); + } + + return bindings.ResolveTemplate( + "ID_ActionKeyMap_OverwriteExistingBindings", + new Dictionary + { + [KeyVariable] = keyName, + [BindingsVariable] = string.Join("\n", lines), + }); } private void ApplySlot(RowView view, int slot, KeyChord chord) { List updated = new(view.Model.Current); - while (updated.Count <= slot) updated.Add(default); - updated[slot] = chord; + // SetBinding @ 0x00487B32..0x00487B47 clamps a requested slot past + // m_qclCurrent.Count to Count. Retail's bindings are a dense list: + // clicking Mapping 3 on an empty row appends at Mapping 1; clicking it + // on a one-binding row appends at Mapping 2. + int targetSlot = Math.Clamp(slot, 0, updated.Count); + if (targetSlot == updated.Count) + updated.Add(chord); + else + updated[targetSlot] = chord; ReplaceSlotValue(view, updated); RefreshRowButtons(view); } @@ -616,13 +740,9 @@ public sealed class KeyboardConfigController private static void ReplaceSlotValue(RowView view, IReadOnlyList value) { - // S4 (2026-08-11 review): only trim TRAILING empty slots. Retail's - // SetBinding(qc, slot) writes the SPECIFIC slot the user clicked — a row - // with no bindings whose "Mapping 3" button is set must keep the chord at - // display index 2, not collapse it onto index 0. Interior default(KeyChord) - // entries only ever come from ApplySlot's own padding, so trimming just the - // tail keeps RefreshRowButtons' positional read correct without inventing a - // nullable-chord storage type. + // The production path is dense (ApplySlot clamps to Count and erase + // removes an element). Keep the trailing-default trim as a defensive + // boundary for compatibility stores created by older schema versions. int lastReal = -1; for (int i = 0; i < value.Count; i++) if (value[i] != default) lastReal = i; @@ -639,9 +759,8 @@ public sealed class KeyboardConfigController /// ICIDM::FindConflictingInputMaps/FindConflictingControls), /// scoped to this screen's own universe: the non-user-bindable check runs /// FIRST (S1 — retail's own order), then EVERY OTHER row's current chord set - /// (covers BOTH mapped and unmapped rows — a chord already claimed by an - /// unmapped row is just as real a conflict as one claimed by a mapped one) is - /// collected in full, not just the first match. + /// (all 306 installed EoR rows are mapped) is collected in full, not just + /// the first match. /// private (ConflictOutcome Outcome, List Rows) FindConflicts(KeyChord chord, RowView exclude) { @@ -659,15 +778,20 @@ public sealed class KeyboardConfigController foreach (RowView other in _rows) { if (ReferenceEquals(other, exclude)) continue; - // OP8 re-review round 2 R1: store-only rows (MappedAction null — - // the Camera Alternate scheme, Emote/CharacterSettings hotkeys) - // never reach the InputDispatcher, so a chord they display cannot - // actually collide with anything; counting them made the ten - // arrow-key defaults trip a false N-way confirm on any arrow - // rebind. Retail-mapped cross-context sharing (ConflictingMaps — - // the Insert/Delete/End/PageUp/PageDown combat cluster) remains - // deferred as ISSUES #373; only INERT rows are excluded here. + // A future unknown-DAT row never reaches the dispatcher, so its + // display-only chord cannot create a live conflict. #373: mapped + // cross-context sharing consults the + // installed DAT's ActionMap.ConflictingMaps table. In particular, + // the melee/missile/magic contexts legitimately share the retail + // Insert/Delete/End/PageUp/PageDown cluster and must not erase one + // another. if (other.MappedAction is null) continue; + if (_snapshot?.InputMapsConflict( + exclude.InputMapId, + other.InputMapId) != true) + { + continue; + } if (other.Model.Current.Contains(chord)) rows.Add(other); } @@ -677,24 +801,42 @@ public sealed class KeyboardConfigController private static void WireScreenButtons( ImportedLayout layout, KeyboardConfigController controller, Bindings bindings) { - // Load File / Save As — INERT (D4: keybinds.json only, no .keymap - // interchange). Authored, clickable, no handler — same shape as OP3's - // still-inert buttons. - _ = layout.FindElement(LoadButtonId); - _ = layout.FindElement(SaveAsButtonId); - _ = layout.FindElement(FilenameLabelId); + UiText? filename = layout.FindElement(FilenameLabelId) as UiText; + void RefreshFilename() + { + if (filename is null || bindings.CurrentKeymapFilename is null) return; + string value = bindings.CurrentKeymapFilename(); + filename.LinesProvider = () => + new[] { new UiText.Line(value, filename.DefaultColor) }; + } + RefreshFilename(); + + if (layout.FindElement(LoadButtonId) is UiButton loadButton + && bindings.OpenLoadKeymap is { } openLoad) + { + loadButton.OnClick = () => openLoad(() => + { + controller.ReloadRowsFromBindings(bindings); + RefreshFilename(); + }); + } + + if (layout.FindElement(SaveAsButtonId) is UiButton saveAsButton + && bindings.OpenSaveKeymap is { } openSave) + { + saveAsButton.OnClick = () => openSave(RefreshFilename); + } if (layout.FindElement(DefaultsButtonId) is UiButton defaultsButton) defaultsButton.OnClick = () => { - foreach (RowView row in controller._rows) - row.Model.SetDefaultValue(row.Model.DefaultValue); controller.Page.Defaults(); foreach (RowView row in controller._rows) controller.RefreshRowButtons(row); }; if (layout.FindElement(RevertButtonId) is UiButton revertButton) + { revertButton.OnClick = () => { controller.Page.Reset(); @@ -702,16 +844,29 @@ public sealed class KeyboardConfigController controller.RefreshRowButtons(row); }; - // OK — right-click release in retail (idMessage 0x19); ported as a plain - // left-click here, matching every other Campaign OP button (the asymmetry - // is authored-input-only — no user-visible affordance differs, since - // retail's own right-click-release on just this pair of buttons carries - // no distinguishing visual cue either). + // gmKeyboardUI::OnOptionChanged @ 0x004DA890 addresses the + // m_pKeyboardRevertToSavedButton slot through the secondary + // IOptionChangeHandler base. It is Normal (state 1) exactly while + // OptionPage::Changed is true, otherwise Ghosted (state 0xD). + controller.Page.OnOptionChanged = () => + revertButton.Enabled = controller.Page.Changed; + controller.Page.OnOptionChanged(); + } + + // gmKeyboardUI::ListenToElementMessage @ 0x004DD230 handles the + // authored button action/release message (id 0x19, parameter 7). That + // is the ordinary retained-button click path, not evidence of a + // special right-click gesture. if (layout.FindElement(OkButtonId) is UiButton okButton) okButton.OnClick = () => { + bool changed = controller.Page.Changed; + // Retail only rewrites the active keymap when at least one + // row differs; SaveCurrentValues still advances the Revert + // baseline unconditionally. + if (changed) + bindings.Save(); controller.Page.Apply(); - bindings.Save(); bindings.Toggle(); }; @@ -724,4 +879,17 @@ public sealed class KeyboardConfigController bindings.Toggle(); }; } + + private void ReloadRowsFromBindings(Bindings bindings) + { + foreach (RowView row in _rows) + { + IReadOnlyList chords = row.MappedAction is { } action + ? bindings.CurrentForAction(action).Select(static value => value.Chord).ToArray() + : bindings.CurrentForUnmapped((row.InputMapId, row.ActionId)); + row.Model.ReloadCurrentAndSaved(chords); + RefreshRowButtons(row); + } + Page.OnOptionChanged?.Invoke(); + } } diff --git a/src/AcDream.App/UI/Layout/MapHousePanelController.cs b/src/AcDream.App/UI/Layout/MapHousePanelController.cs index b8b5c943..7b0ddb56 100644 --- a/src/AcDream.App/UI/Layout/MapHousePanelController.cs +++ b/src/AcDream.App/UI/Layout/MapHousePanelController.cs @@ -140,6 +140,12 @@ public sealed class MapHousePanelController : IRetainedPanelController public bool IsShowingHouse => _tabPanel.ActivePageElementId == HousePageId; + public bool IsShowingMap => _tabPanel.ActivePageElementId == MapPageId; + + public void ShowMap() => _tabPanel.SwitchTo(MapPageId); + + public void ShowHouse() => _tabPanel.SwitchTo(HousePageId); + public void OnShown() { _visible = true; diff --git a/src/AcDream.App/UI/Layout/OptionPageModel.cs b/src/AcDream.App/UI/Layout/OptionPageModel.cs index 682a67b2..804f4074 100644 --- a/src/AcDream.App/UI/Layout/OptionPageModel.cs +++ b/src/AcDream.App/UI/Layout/OptionPageModel.cs @@ -554,10 +554,10 @@ public sealed class ActionKeyMapOptionRow : IOptionRow public bool Changed => !_current.SequenceEqual(_saved); - /// Reset-to-Defaults reloads the DAT master maps fresh - /// (gmKeyboardUI::RestoreDefaultValues — research doc §5.6) before - /// restoring each row, so the default slot list itself can change between - /// presses (a fresh DAT read), not just at construction time. + /// Replaces the DAT master-map default used by the next + /// Reset-to-Defaults operation. The installed DAT is immutable during one + /// client process, so the keyboard controller normally seeds this once + /// when it builds the row. public void SetDefaultValue(IReadOnlyList value) => _default = value; /// The capture/erase entry point — writes m_current and applies @@ -574,6 +574,16 @@ public sealed class ActionKeyMapOptionRow : IOptionRow public void SaveCurrentValue() => _saved = _current; + /// Re-seeds both the live value and Revert baseline after retail's + /// Load File swaps the dispatcher keymap. The dispatcher has already applied + /// the profile, so this intentionally does not call the row's write-back. + public void ReloadCurrentAndSaved(IReadOnlyList value) + { + _current = value; + _saved = value; + _notifyPageOptionChanged?.Invoke(); + } + public void RestoreSavedValue() { _current = _saved; diff --git a/src/AcDream.App/UI/Layout/OptionsPanelController.cs b/src/AcDream.App/UI/Layout/OptionsPanelController.cs index 29d1d423..90c4b520 100644 --- a/src/AcDream.App/UI/Layout/OptionsPanelController.cs +++ b/src/AcDream.App/UI/Layout/OptionsPanelController.cs @@ -134,6 +134,28 @@ public sealed class OptionsPanelController : IRetainedPanelController public OptionPage ConfigPage => _pages[ConfigPageId]; + /// True when the authored Gameplay Options page is active. + public bool IsShowingGameplay => + _tabPanel.ActivePageElementId == GameplayPageId; + + public bool IsShowingCharacter => + _tabPanel.ActivePageElementId == CharacterPageId; + + public bool IsShowingConfiguration => + _tabPanel.ActivePageElementId == ConfigPageId; + + /// + /// Programmatic form of retail action 0x1000001B, resolved from + /// the installed ActionMap as "Show/Hide Gameplay Options Page". This is + /// the final fallback of ClientUISystem::OnAction(EscapeKey) at + /// 0x00564CBF. + /// + public void ShowGameplay() => _tabPanel.SwitchTo(GameplayPageId); + + public void ShowCharacter() => _tabPanel.SwitchTo(CharacterPageId); + + public void ShowConfiguration() => _tabPanel.SwitchTo(ConfigPageId); + private OptionsPanelController(UiTabPanel tabPanel, Action? afterApply) { _tabPanel = tabPanel; diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs index 4d925ed9..c061e0ac 100644 --- a/src/AcDream.App/UI/Layout/PaperdollController.cs +++ b/src/AcDream.App/UI/Layout/PaperdollController.cs @@ -115,6 +115,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo _objects.ObjectUpdated += OnObjectChanged; _objects.Cleared += OnObjectsCleared; _selection.Changed += OnSelectionChanged; + _itemInteraction.StateChanged += OnInteractionStateChanged; // ── Slots-toggle wiring ─────────────────────────────────────────────────────────────────── foreach (var id in ArmorSlotElementIds) @@ -216,6 +217,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo Populate(); } private void OnSelectionChanged(SelectionTransition _) => ApplySelectionIndicators(); + private void OnInteractionStateChanged() => Populate(); private void OnObjectsCleared() { ApplyAetheriaVisibility(); @@ -225,8 +227,8 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo /// The object belongs to the player (wielded gear or pack contents) — so a change to it may /// add/remove/repaint a doll slot. Player-scoped: an NPC's or vendor's wielded item (which also carries /// CurrentlyEquippedLocation from the wire) must NOT trigger a repaint. A player-equipped item always - /// has WielderId==p (login, from CreateObject) or ContainerId==p (live/optimistic wield, set by - /// WieldItemOptimistic), so the equip-location need not be tested here; OnObjectMoved carries the + /// has WielderId==p (login, from CreateObject) or ContainerId==p, so the + /// equip-location need not be tested here; OnObjectMoved carries the /// complete old/new retail placement for transitions that satisfy neither after mutation. private bool Concerns(ClientObject o) { @@ -256,6 +258,8 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo uint dragTex = _dragIconIds?.Invoke( worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects) ?? 0u; list.Cell.SetItem(worn.ObjectId, tex, dragIconTexture: dragTex); + list.Cell.SetWaitingState( + _itemInteraction.IsPendingInventorySource(worn.ObjectId)); } ApplyAetheriaVisibility(); ApplySelectionIndicators(); @@ -278,7 +282,8 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo foreach (var (_, list) in _slots) { list.Cell.Selected = list.Cell.ItemId != 0 - && list.Cell.ItemId == _selection.SelectedObjectId; + && list.Cell.ItemId == _selection.SelectedObjectId + && !_itemInteraction.IsPendingInventorySource(list.Cell.ItemId); } } @@ -369,6 +374,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo _objects.ObjectUpdated -= OnObjectChanged; _objects.Cleared -= OnObjectsCleared; _selection.Changed -= OnSelectionChanged; + _itemInteraction.StateChanged -= OnInteractionStateChanged; foreach (var (_, list) in _slots) { list.PrimaryItemPressed = null; diff --git a/src/AcDream.App/UI/Layout/RetailConfirmationMenuDialogView.cs b/src/AcDream.App/UI/Layout/RetailConfirmationMenuDialogView.cs new file mode 100644 index 00000000..ba951cf5 --- /dev/null +++ b/src/AcDream.App/UI/Layout/RetailConfirmationMenuDialogView.cs @@ -0,0 +1,122 @@ +using AcDream.App.UI; + +namespace AcDream.App.UI.Layout; + +/// Retail type-7 ConfirmationMenuDialog, used by Configure +/// Keyboard's authored Load File button. +internal sealed class RetailConfirmationMenuDialogView : IRetailDialogView +{ + public const uint RootElementId = 0x1Fu; + public const uint MenuElementId = 0x21u; + public const uint AcceptButtonId = 0x22u; + public const uint RejectButtonId = 0x23u; + public const uint PopupElementId = 0x3Du; + + private readonly UiRoot _host; + private readonly RetailDialogData _data; + private readonly uint _context; + private readonly Action _closeDialog; + private readonly UiElement? _popup; + private readonly UiMenu _menu; + private readonly UiButton _accept; + private readonly UiButton _reject; + + public RetailConfirmationMenuDialogView( + UiRoot host, + ImportedLayout layout, + RetailDialogData data, + uint context, + Action closeDialog) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + ArgumentNullException.ThrowIfNull(layout); + _data = data ?? throw new ArgumentNullException(nameof(data)); + _context = context; + _closeDialog = closeDialog ?? throw new ArgumentNullException(nameof(closeDialog)); + + Root = layout.Root as UiDialogRoot + ?? throw new ArgumentException( + "Confirmation-menu layout root is not a UiDialogRoot.", nameof(layout)); + _popup = layout.FindElement(PopupElementId); + _menu = layout.FindElement(MenuElementId) as UiMenu + ?? throw new ArgumentException( + "Confirmation-menu layout is missing menu element 0x21.", nameof(layout)); + _accept = layout.FindElement(AcceptButtonId) as UiButton + ?? throw new ArgumentException( + "Confirmation-menu layout is missing accept button 0x22.", nameof(layout)); + _reject = layout.FindElement(RejectButtonId) as UiButton + ?? throw new ArgumentException( + "Confirmation-menu layout is missing reject button 0x23.", nameof(layout)); + + IReadOnlyList items = _data.TryGet( + RetailDialogProperty.MenuItems, out string[] values) + ? values + : Array.Empty(); + _menu.Items = items.Select( + static (label, index) => new UiMenu.MenuItem(label, index)).ToArray(); + int selected = Math.Clamp( + _data.GetInt32(RetailDialogProperty.MenuSelection), + 0, + Math.Max(0, items.Count - 1)); + _menu.Selected = items.Count == 0 ? null : selected; + _menu.OnSelect = payload => _menu.Selected = payload; + _menu.ButtonLabelProvider = () => + _menu.Selected is int index && index >= 0 && index < items.Count + ? items[index] + : string.Empty; + + if (_data.GetString(RetailDialogProperty.MenuAcceptLabel) is { } acceptLabel) + _accept.Label = acceptLabel; + if (_data.GetString(RetailDialogProperty.MenuRejectLabel) is { } rejectLabel) + _reject.Label = rejectLabel; + + Root.Cancel = Reject; + _accept.OnClick = Accept; + _reject.OnClick = Reject; + SizeAndCenter(); + } + + public UiDialogRoot Root { get; } + + public void Tick() => SizeAndCenter(); + + public void SetPendingCount(int count) + { + } + + public void DetachHandlers() + { + Root.Cancel = null; + _accept.OnClick = null; + _reject.OnClick = null; + _menu.OnSelect = null; + } + + private void Accept() + { + _data.Set( + RetailDialogProperty.MenuSelection, + _menu.Selected is int selected ? selected : -1); + _closeDialog(_context); + } + + private void Reject() + { + _data.Set(RetailDialogProperty.MenuSelection, -1); + _closeDialog(_context); + } + + private void SizeAndCenter() + { + var space = _host.EffectiveCanvasSize; + Root.Left = 0f; + Root.Top = 0f; + Root.Width = space.X; + Root.Height = space.Y; + if (_popup is null) return; + _popup.LayoutPolicy = null; + _popup.Anchors = AnchorEdges.None; + _popup.Left = MathF.Round((Root.Width - _popup.Width) * 0.5f); + _popup.Top = MathF.Round((Root.Height - _popup.Height) * 0.5f); + } +} diff --git a/src/AcDream.App/UI/Layout/RetailDialogData.cs b/src/AcDream.App/UI/Layout/RetailDialogData.cs index 3dd769e5..75361a59 100644 --- a/src/AcDream.App/UI/Layout/RetailDialogData.cs +++ b/src/AcDream.App/UI/Layout/RetailDialogData.cs @@ -14,6 +14,11 @@ public static class RetailDialogProperty public const uint TextInputAcceptLabel = 0x9Au; public const uint TextInputRejectLabel = 0x9Bu; public const uint TextInputResult = 0x9Cu; + public const uint MenuItems = 0xA6u; + public const uint MenuItem = 0xA7u; + public const uint MenuAcceptLabel = 0xA8u; + public const uint MenuRejectLabel = 0xA9u; + public const uint MenuSelection = 0xABu; /// /// When true, Dialog::SetData @ 0x00476BE0 sets UIElement boolean /// attribute 0x40. The Keystone-owned attribute name is unavailable. @@ -97,6 +102,19 @@ public sealed class RetailDialogData } : defaultValue; + public int GetInt32(uint propertyId, int defaultValue = 0) + => _values.TryGetValue(propertyId, out object? raw) + ? raw switch + { + byte value => value, + ushort value => value, + int value => value, + uint value when value <= int.MaxValue => (int)value, + Enum value => Convert.ToInt32(value), + _ => defaultValue, + } + : defaultValue; + public string? GetString(uint propertyId) => _values.TryGetValue(propertyId, out object? raw) ? raw as string : null; @@ -148,4 +166,18 @@ public sealed class RetailDialogData .Set(RetailDialogProperty.ElementAttribute40, true) .Set(RetailDialogProperty.Message, message); } + + /// Type-7 confirmation menu used by retail's keyboard-profile + /// Load File workflow (gmKeyboardUI::MakeLoadKeymapDialog). + public static RetailDialogData ConfirmationMenu( + IReadOnlyList items, + int selectedIndex = 0) + { + ArgumentNullException.ThrowIfNull(items); + return new RetailDialogData() + .Set(RetailDialogProperty.Type, RetailDialogType.ConfirmationMenu) + .Set(RetailDialogProperty.ElementAttribute40, true) + .Set(RetailDialogProperty.MenuItems, items.ToArray()) + .Set(RetailDialogProperty.MenuSelection, selectedIndex); + } } diff --git a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs index 9efc211c..1970e18a 100644 --- a/src/AcDream.App/UI/Layout/RetailDialogFactory.cs +++ b/src/AcDream.App/UI/Layout/RetailDialogFactory.cs @@ -169,20 +169,28 @@ public sealed class RetailDialogFactory : IDisposable /// UIOption_ActionKeyMap::OpenMapWarnDialog @ 0x00488A00: type 2, /// caller-chosen queue key, element attribute 0x40 set, message text. /// - public uint MakeWait(string message, uint queueKey = DefaultQueueKey) + public uint MakeWait( + string message, + uint queueKey = DefaultQueueKey, + bool priority = false) { RetailDialogData data = RetailDialogData.Wait(message) .Set(RetailDialogProperty.QueueKey, queueKey); + if (priority) + data.Set(RetailDialogProperty.Priority, true); return MakeDialog(data, callback: null); } public uint MakeMessage( string message, Action? callback = null, - uint queueKey = DefaultQueueKey) + uint queueKey = DefaultQueueKey, + bool priority = false) { RetailDialogData data = RetailDialogData.Message(message) .Set(RetailDialogProperty.QueueKey, queueKey); + if (priority) + data.Set(RetailDialogProperty.Priority, true); return MakeDialog(data, callback); } @@ -196,6 +204,17 @@ public sealed class RetailDialogFactory : IDisposable return MakeDialog(data, callback); } + public uint MakeConfirmationMenu( + IReadOnlyList items, + int selectedIndex, + Action? callback = null, + uint queueKey = DefaultQueueKey) + { + RetailDialogData data = RetailDialogData.ConfirmationMenu(items, selectedIndex) + .Set(RetailDialogProperty.QueueKey, queueKey); + return MakeDialog(data, callback); + } + /// /// Retail CloseDialog @ 0x00478160. The context can identify an active /// nonqueued dialog, an active queued dialog, or an item still pending in a queue. @@ -395,7 +414,8 @@ public sealed class RetailDialogFactory : IDisposable if (type is not (RetailDialogType.Confirmation or RetailDialogType.Wait or RetailDialogType.Message - or RetailDialogType.ConfirmationTextInput)) + or RetailDialogType.ConfirmationTextInput + or RetailDialogType.ConfirmationMenu)) { throw new NotSupportedException( $"Retail dialog type {(uint)type} does not have a ported presenter yet."); @@ -415,6 +435,10 @@ public sealed class RetailDialogFactory : IDisposable new RetailConfirmationTextInputDialogView( _host, layout, info.Data, info.Context, context => CloseDialog(context)), + RetailDialogType.ConfirmationMenu => + new RetailConfirmationMenuDialogView( + _host, layout, info.Data, info.Context, + context => CloseDialog(context)), _ => new RetailConfirmationDialogView( _host, layout, info.Data, info.Context, context => CloseDialog(context)), diff --git a/src/AcDream.App/UI/Layout/RetailKeyNames.cs b/src/AcDream.App/UI/Layout/RetailKeyNames.cs index 770bb90b..a641fa61 100644 --- a/src/AcDream.App/UI/Layout/RetailKeyNames.cs +++ b/src/AcDream.App/UI/Layout/RetailKeyNames.cs @@ -37,8 +37,7 @@ namespace AcDream.App.UI.Layout; /// / DIK_LMENU), names its METAKEY through the meta table + OS fallback and /// joins with the authored ID_KeyDescDelimiter ("+", table enum 3 → /// DID 0x23000007). A binding whose KEY IS a modifier key (retail's -/// walk-mode DIK_LSHIFT row has meta-mode 0; acdream's -/// carries the wire-side self-modifier bit) shows only the key name — never +/// walk-mode DIK_LSHIFT row has meta-mode 0) shows only the key name — never /// "Shift+ShiftLeft". /// /// @@ -79,21 +78,34 @@ public sealed class RetailKeyNames /// /// Display name for one bound chord — retail - /// GetNameFromKey(QualifiedControl). Mouse chords keep the - /// pre-existing enum spelling: retail names mouse controls through the - /// DirectInput mouse device, which this port does not have (AD-95a). + /// GetNameFromKey(QualifiedControl). Mouse controls use retail's + /// DIMOFS semantic/table lookup. If the table misses, DirectInput would + /// provide a localized object name; acdream's non-DirectInput fallback is + /// the stable user-facing "Mouse Button N". /// public string Describe(KeyChord chord) { if (chord == default) return string.Empty; + if (TryGetMouseSemantic(chord, out string? mouseSemantic, out int buttonNumber)) + { + string mouseName = _resolveString( + KeyNameTableId, + DatStringResolver.ComputeHash(mouseSemantic!)) + ?? $"Mouse Button {buttonNumber}"; + return Compose(chord, mouseName); + } if (!TryGetDik(chord.Key, out byte dik, out string? dikName)) return FallbackSpelling(chord); + return Compose(chord, LookupName(dikName!, dik, KeyNameTableId)); + } + + private string Compose(KeyChord chord, string keyName) + { var composed = new System.Text.StringBuilder(); // Meta-mode bits ascending, skipping the key's own self-modifier bit - // (retail's walk-mode LSHIFT row carries meta-mode 0 on the wire; the - // chord's stored self bit is acdream's encoding, not display truth). + // (retail's walk-mode LSHIFT row carries meta-mode 0 on the wire). foreach ((ModifierMask flag, Key metaKey) in MetaOrder) { if ((chord.Modifiers & flag) == 0 || IsSelfModifier(chord.Key, flag)) @@ -104,10 +116,36 @@ public sealed class RetailKeyNames composed.Append(_delimiter); } - composed.Append(LookupName(dikName!, dik, KeyNameTableId)); + composed.Append(keyName); return composed.ToString(); } + private static bool TryGetMouseSemantic( + KeyChord chord, + out string? semantic, + out int buttonNumber) + { + int zeroBased = (int)chord.Key switch + { + -1001 => 0, + -1002 => 1, + -1003 => 2, + -1004 => 3, + -1005 => 4, + _ => -1, + }; + if (chord.Device != 1 || zeroBased < 0) + { + semantic = null; + buttonNumber = 0; + return false; + } + + semantic = $"DIMOFS_BUTTON{zeroBased}"; + buttonNumber = zeroBased + 1; + return true; + } + private string LookupName(string dikName, byte dik, uint tableId) => _resolveString(tableId, DatStringResolver.ComputeHash(dikName)) ?? _osKeyName?.Invoke((byte)(dik & 0x7F), (dik & 0x80) != 0) @@ -126,6 +164,7 @@ public sealed class RetailKeyNames (ModifierMask.Shift, Key.ShiftLeft), (ModifierMask.Ctrl, Key.ControlLeft), (ModifierMask.Alt, Key.AltLeft), + (ModifierMask.Win, Key.SuperLeft), }; private static bool IsSelfModifier(Key key, ModifierMask flag) @@ -134,15 +173,15 @@ public sealed class RetailKeyNames ModifierMask.Shift => key is Key.ShiftLeft or Key.ShiftRight, ModifierMask.Ctrl => key is Key.ControlLeft or Key.ControlRight, ModifierMask.Alt => key is Key.AltLeft or Key.AltRight, + ModifierMask.Win => key is Key.SuperLeft or Key.SuperRight, _ => false, }; /// /// Silk key → DirectInput scan code + DIK name — the reverse of - /// 's keyboard table (same 84 - /// DAT-observed codes) plus the modifier keys live capture can produce - /// that no DAT default binds directly (DIK_LCONTROL 0x1D, DIK_LMENU 0x38, - /// DIK_RMENU 0xB8). DIK codes with bit 0x80 are the extended set — the + /// 's keyboard table: the 84 + /// DAT-default codes plus the additional controls accepted by retail's + /// plain-text keymap format. DIK codes with bit 0x80 are the extended set — the /// same split Win32's GetKeyNameText expects in bit 24. /// private static bool TryGetDik(Key key, out byte dik, out string? name) @@ -206,6 +245,7 @@ public sealed class RetailKeyNames Key.KeypadMultiply => ((byte)0x37, "DIK_MULTIPLY"), Key.AltLeft => ((byte)0x38, "DIK_LMENU"), Key.Space => ((byte)0x39, "DIK_SPACE"), + Key.CapsLock => ((byte)0x3A, "DIK_CAPITAL"), Key.F1 => ((byte)0x3B, "DIK_F1"), Key.F2 => ((byte)0x3C, "DIK_F2"), Key.F3 => ((byte)0x3D, "DIK_F3"), @@ -233,10 +273,15 @@ public sealed class RetailKeyNames Key.KeypadDecimal => ((byte)0x53, "DIK_DECIMAL"), Key.F11 => ((byte)0x57, "DIK_F11"), Key.F12 => ((byte)0x58, "DIK_F12"), + Key.F13 => ((byte)0x64, "DIK_F13"), + Key.F14 => ((byte)0x65, "DIK_F14"), + Key.F15 => ((byte)0x66, "DIK_F15"), Key.KeypadEnter => ((byte)0x9C, "DIK_NUMPADENTER"), Key.ControlRight => ((byte)0x9D, "DIK_RCONTROL"), Key.KeypadDivide => ((byte)0xB5, "DIK_DIVIDE"), + Key.PrintScreen => ((byte)0xB7, "DIK_SYSRQ"), Key.AltRight => ((byte)0xB8, "DIK_RMENU"), + Key.Pause => ((byte)0xC5, "DIK_PAUSE"), Key.Home => ((byte)0xC7, "DIK_HOME"), Key.Up => ((byte)0xC8, "DIK_UP"), Key.PageUp => ((byte)0xC9, "DIK_PRIOR"), @@ -247,6 +292,9 @@ public sealed class RetailKeyNames Key.PageDown => ((byte)0xD1, "DIK_NEXT"), Key.Insert => ((byte)0xD2, "DIK_INSERT"), Key.Delete => ((byte)0xD3, "DIK_DELETE"), + Key.SuperLeft => ((byte)0xDB, "DIK_LWIN"), + Key.SuperRight => ((byte)0xDC, "DIK_RWIN"), + Key.Menu => ((byte)0xDD, "DIK_APPS"), _ => ((byte)0, null), }; return name is not null; diff --git a/src/AcDream.App/UI/Layout/SelectedObjectController.cs b/src/AcDream.App/UI/Layout/SelectedObjectController.cs index 1f4ba9c7..34d0aa42 100644 --- a/src/AcDream.App/UI/Layout/SelectedObjectController.cs +++ b/src/AcDream.App/UI/Layout/SelectedObjectController.cs @@ -94,6 +94,8 @@ public sealed class SelectedObjectController : IRetainedPanelController private readonly StackSplitQuantityState _splitQuantity; private readonly SelectionState _selection; private readonly Func _isVendorSplitExempt; + private readonly Func _isCoinstack; + private readonly Func _coinTotal; private readonly Action> _unsubscribeHealthChanged; private readonly Action> _unsubscribeItemManaChanged; private readonly Action> _unsubscribeObjectUpdated; @@ -128,7 +130,9 @@ public sealed class SelectedObjectController : IRetainedPanelController StackSplitQuantityState splitQuantity, Action> subscribeObjectUpdated, Action> unsubscribeObjectUpdated, - Func isVendorSplitExempt) + Func isVendorSplitExempt, + Func? isCoinstack, + Func? coinTotal) { _isHealthTarget = isHealthTarget; _isOwnedByPlayer = isOwnedByPlayer; @@ -143,6 +147,8 @@ public sealed class SelectedObjectController : IRetainedPanelController _selection = selection ?? throw new ArgumentNullException(nameof(selection)); _isVendorSplitExempt = isVendorSplitExempt ?? throw new ArgumentNullException(nameof(isVendorSplitExempt)); + _isCoinstack = isCoinstack ?? (_ => false); + _coinTotal = coinTotal ?? (() => 0); _unsubscribeHealthChanged = unsubscribeHealthChanged; _unsubscribeItemManaChanged = unsubscribeItemManaChanged; _unsubscribeObjectUpdated = unsubscribeObjectUpdated; @@ -319,7 +325,9 @@ public sealed class SelectedObjectController : IRetainedPanelController StackSplitQuantityState splitQuantity, Action> subscribeObjectUpdated, Action> unsubscribeObjectUpdated, - Func isVendorSplitExempt) + Func isVendorSplitExempt, + Func? isCoinstack = null, + Func? coinTotal = null) => new SelectedObjectController( layout, selection, subscribeHealthChanged, unsubscribeHealthChanged, @@ -327,7 +335,7 @@ public sealed class SelectedObjectController : IRetainedPanelController isHealthTarget, isOwnedByPlayer, name, healthPercent, hasHealth, stackSize, sendQueryHealth, manaPercent, sendQueryItemMana, datFont, splitQuantity, subscribeObjectUpdated, unsubscribeObjectUpdated, - isVendorSplitExempt); + isVendorSplitExempt, isCoinstack, coinTotal); /// /// Port of gmToolbarUI::HandleSelectionChanged (:198635): @@ -373,9 +381,11 @@ public sealed class SelectedObjectController : IRetainedPanelController // ── 2. Name (displayed via the UiText child's LinesProvider reading _currentName). ── uint stackSize = _stackSize(g); string? objectName = _resolveName(g); - _currentName = stackSize > 1u && !string.IsNullOrEmpty(objectName) - ? $"{stackSize} {objectName}" - : objectName; + _currentName = _isCoinstack(g) && _isOwnedByPlayer(g) + ? $"{stackSize} {objectName} (of {_coinTotal()})" + : stackSize > 1u && !string.IsNullOrEmpty(objectName) + ? $"{stackSize} {objectName}" + : objectName; // ── 3. Selection overlay: brief flash (retail container ObjectSelected // = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ────────────── @@ -522,6 +532,26 @@ public sealed class SelectedObjectController : IRetainedPanelController } } + /// + /// Retail gmToolbarUI::RecvNotice_SplitStack @ 0x004BD2A0: when + /// the notice still names the selected stack and its size is greater than + /// one, focus the numeric quantity field and select all of its text. + /// + public bool FocusSplitStackEntry(uint objectId) + { + if (_current != objectId + || _stackSize(objectId) <= 1u + || _stackSizeEntry is null + || !_stackSizeEntry.Visible) + { + return false; + } + + _stackSizeEntry.FindRoot()?.SetKeyboardFocus(_stackSizeEntry); + _stackSizeEntry.SelectAllText(); + return true; + } + private void OnObjectUpdated(ClientObject updated) { if (_current == updated.ObjectId && _stackSize(updated.ObjectId) != _splitQuantity.Maximum) diff --git a/src/AcDream.App/UI/Layout/SocialPanelController.cs b/src/AcDream.App/UI/Layout/SocialPanelController.cs index 2ae0d719..50316f5f 100644 --- a/src/AcDream.App/UI/Layout/SocialPanelController.cs +++ b/src/AcDream.App/UI/Layout/SocialPanelController.cs @@ -243,6 +243,8 @@ public sealed class SocialPanelController : IRetainedPanelController /// F4 ToggleFellowshipPanel's tab-switch half. public void ShowFellowship() => _tabPanel.SwitchTo(FellowshipPageId); + public void ShowFriends() => _tabPanel.SwitchTo(FriendsPageId); + /// True when the Allegiance tab is the active page — lets /// implement the /// close-on-second-press-of-the-SAME-tab semantics every other @@ -264,6 +266,8 @@ public sealed class SocialPanelController : IRetainedPanelController /// True when the Fellowship tab is the active page. public bool IsShowingFellowship => _tabPanel.ActivePageElementId == FellowshipPageId; + public bool IsShowingFriends => _tabPanel.ActivePageElementId == FriendsPageId; + /// True while the social panel's own window is shown — set by /// /. Fix-round blast SF-2: /// gates the Friends/Squelch rebuild (see ) so their diff --git a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs index 2d4bc0ac..dc888539 100644 --- a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs +++ b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs @@ -216,9 +216,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController public bool Handle(InputAction action) { - if (action is >= InputAction.UseSpellSlot_1 and <= InputAction.UseSpellSlot_9) + if (TryMapSpellShortcut(action, out int index)) { - int index = (int)action - (int)InputAction.UseSpellSlot_1; IReadOnlyList spells = _spellbook.GetFavorites(_activeTab); if (index < spells.Count) { @@ -243,6 +242,27 @@ public sealed class SpellcastingUiController : IRetainedPanelController } } + internal static bool TryMapSpellShortcut( + InputAction action, + out int index) + { + if (action is >= InputAction.UseSpellSlot_1 + and <= InputAction.UseSpellSlot_9) + { + index = (int)action - (int)InputAction.UseSpellSlot_1; + return true; + } + + index = action switch + { + InputAction.UseSpellSlot_10 => 9, + InputAction.UseSpellSlot_11 => 10, + InputAction.UseSpellSlot_12 => 11, + _ => -1, + }; + return index >= 0; + } + private void SelectTab(int tab) { _activeTab = Math.Clamp(tab, 0, 7); diff --git a/src/AcDream.App/UI/Layout/ToolbarInputController.cs b/src/AcDream.App/UI/Layout/ToolbarInputController.cs index d298da41..4c9605b5 100644 --- a/src/AcDream.App/UI/Layout/ToolbarInputController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarInputController.cs @@ -56,6 +56,23 @@ public sealed class ToolbarInputController return true; } + if (action is InputAction.UseQuickSlot_10 + or InputAction.UseQuickSlot_11 + or InputAction.UseQuickSlot_12 + or InputAction.UseQuickSlot_13) + { + slot = action switch + { + InputAction.UseQuickSlot_10 => 9, + InputAction.UseQuickSlot_11 => 10, + InputAction.UseQuickSlot_12 => 11, + InputAction.UseQuickSlot_13 => 12, + _ => -1, + }; + use = true; + return true; + } + if (value >= (int)InputAction.UseQuickSlot_14 && value <= (int)InputAction.UseQuickSlot_18) { diff --git a/src/AcDream.App/UI/Layout/VendorUiController.cs b/src/AcDream.App/UI/Layout/VendorUiController.cs index f6dd3231..247268f9 100644 --- a/src/AcDream.App/UI/Layout/VendorUiController.cs +++ b/src/AcDream.App/UI/Layout/VendorUiController.cs @@ -381,10 +381,18 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // X-close confirmation is already up; HandleButtonClicks' 0x100000d6 // case only opens a NEW one when this is 0 (pc:204155). private uint _closeConfirmContext; + private int _lastAlternateCurrencyPurchase; + private bool _alternateCurrencyInventoryObserved; + private PendingVendorSplit? _pendingVendorSplit; // F5: see DragOverGlobalTimeSink's own doc comment. private readonly DragOverGlobalTimeSink _dragOverSink; private bool _disposed; + private readonly record struct PendingVendorSplit( + uint SourceGuid, + uint WeenieClassId, + int Quantity); + private VendorUiController( VendorState vendor, RetailWindowHandle window, @@ -499,6 +507,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // succeeds and this stops being a dead end — closes half of AP-161 // finding #2. _itemList.ExamineItemRequested = ExamineItem; + _itemList.PrimaryItemPressed = PressVendorItem; if (itemScrollbar is not null) { itemScrollbar.Model = _itemList.Scroll; @@ -528,6 +537,16 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // gate (pc:204229-204246) — the Selling tab's list is the ONLY drop // target. UiItemList.RegisterDragHandler is the structural analogue. _sellingList?.RegisterDragHandler(this); + if (_buyingList is not null) + { + _buyingList.PrimaryItemPressed = PressVendorItem; + _buyingList.ExamineItemRequested = ExamineItem; + } + if (_sellingList is not null) + { + _sellingList.PrimaryItemPressed = PressVendorItem; + _sellingList.ExamineItemRequested = ExamineItem; + } // F5: mount the global-time sink so a live drag hovering anywhere // over this window auto-switches to the Selling tab — see @@ -637,7 +656,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // separate "staging changed" gate from "holdings changed" (both // UpdateTotalValue calls read the LIVE holding fresh, same as // BuildCostText's own PropertyInt.CoinValue read). + _objects.ObjectAdded += OnObjectAdded; _objects.ObjectUpdated += OnObjectMoneyChanged; + _objects.StackSizeUpdated += OnStackSizeUpdated; + _objects.ObjectMoved += OnObjectMoved; ShowTab(VendorPanelTab.Items); ClearContent(); @@ -661,6 +683,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // mechanism every other panel already uses, not a vendor-specific // special case. _objects.ObjectRemoved += OnObjectRemoved; + _itemInteraction.RuntimeTransactions.Inventory.RequestFailed += OnInventoryRequestFailed; // Slice 6.3: mirrors ExternalContainerController's own // _itemInteraction.StateChanged subscription — the Buy button must // disable the instant a reservation is taken (BeginUseRequestReservation @@ -866,6 +889,14 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag private void ShowTab(VendorPanelTab tab) { + // gmVendorUI::OpenTab resets m_last_sale. Authoritative inventory + // remains the preferred source; this only clears the optimistic + // post-buy subtraction used before that update arrives. + if (_lastAlternateCurrencyPurchase != 0) + { + _lastAlternateCurrencyPurchase = 0; + RefreshMoneyText(); + } _itemsPage.Visible = tab == VendorPanelTab.Items; _buyingPage.Visible = tab == VendorPanelTab.Buying; _sellingPage.Visible = tab == VendorPanelTab.Selling; @@ -894,6 +925,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // staging lists the same way the category selection resets. _buyStaging.Clear(); _sellStaging.Clear(); + _pendingVendorSplit = null; + ResetAlternateCurrencyTracking(); + RefreshMoneyText(); _selectedCategoryIndex = -1; ShowTab(VendorPanelTab.Items); RebuildCategories(); @@ -910,6 +944,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // the time this fires the relevant list is already empty in // the normal flow, and the OTHER (untouched) list must // survive a refresh triggered by its sibling. + ResetAlternateCurrencyTracking(); + RefreshMoneyText(); ShowTab(VendorPanelTab.Items); RebuildCategories(); _window.Show(); @@ -920,6 +956,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // the session (contract's C2/C3 close semantics). _buyStaging.Clear(); _sellStaging.Clear(); + _pendingVendorSplit = null; + ResetAlternateCurrencyTracking(); ClearContent(); ShowTab(VendorPanelTab.Items); _window.Hide(); @@ -1112,15 +1150,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag cell.SetItem(item.ItemGuid, icon); cell.Selected = item.ItemGuid == selectedGuid; VendorShopItem captured = item; - cell.Clicked = () => _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor); - // AP-171: double-click buys the item — a DELIBERATE, - // user-approved modernization. Retail has NO - // double-click-to-buy anywhere in the named function - // table (negative evidence recorded at the Slice 6 - // research); the user requested it explicitly - // 2026-08-08 after being told so. Select-then-buy so - // the quantity/price path is identical to the Buy - // button's. + cell.Clicked = () => + _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor); + // gmVendorUI::HandleMousePresses @ 0x004C40D0: a + // double-click in the browse list calls BuySingleItem. cell.DoubleClicked = () => { _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor); @@ -1252,14 +1285,23 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag SetPlainText(_itemNameText, nameText); VendorShopProfile profile = _vendor.Profile; - int rawValue = item.Value ?? 0; - int perUnit = VendorPricing.PerUnitValue(rawValue, item.DescStackSize); - int price = VendorPricing.SellPrice(perUnit, item.ItemType ?? 0u, profile.SellPrice, quantity); + int price = ComputeShopItemPrice(item, quantity); SetPlainText(_itemCostText, BuildCostText(profile, quantity, price)); SetActionButtonsEnabled(true); } + private int ComputeShopItemPrice(VendorShopItem item, int quantity) + { + int rawValue = item.Value ?? 0; + int perUnit = VendorPricing.PerUnitValue(rawValue, item.DescStackSize); + return VendorPricing.SellPrice( + perUnit, + item.ItemType ?? 0u, + _vendor.Profile.SellPrice, + quantity); + } + /// /// Right-click examine on a shop row — mirrors /// ExternalContainerController.ExamineItem's "select then @@ -1276,6 +1318,13 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag _itemInteraction.ExamineSelectedOrEnterMode(guid); } + private bool PressVendorItem(uint guid) + { + if (guid != 0u) + _selection.Select(guid, SelectionChangeSource.Vendor); + return false; + } + /// /// Slice 6.2: reacts to ANY global selection change, not just ones this /// panel originated — mirrors ExternalContainerController.OnSelectionChanged. @@ -1390,6 +1439,15 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// private void OnObjectRemoved(ClientObject item) { + if (IsCurrentAlternateCurrency(item)) + { + _alternateCurrencyInventoryObserved = true; + _lastAlternateCurrencyPurchase = 0; + RefreshMoneyText(); + } + if (_pendingVendorSplit is { } split && split.SourceGuid == item.ObjectId) + _pendingVendorSplit = null; + if (_selection.SelectedObjectId == item.ObjectId) { _selection.Clear( @@ -1463,11 +1521,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// (pc:203494-203497) via the SAME /// generic int-property bundle every other PropertyInt-driven display /// reads. The alt-currency holding is retail's - /// shopVendorProfile->trade_num - m_last_sale; - /// m_last_sale only changes on a completed Slice-6 purchase, so - /// with no purchase mechanism yet this port uses - /// directly - /// (retail's m_last_sale == 0 case — see the register, AP-161). + /// shopVendorProfile->trade_num - m_last_sale. This controller + /// mirrors the immediate subtraction after dispatch and then reconciles + /// to the authoritative player-owned currency stacks when their object + /// updates arrive; the profile amount is only the pre-observation fallback. /// /// private string BuildCostText(VendorShopProfile profile, int quantity, int price) @@ -1479,7 +1536,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag "This item costs {0} {1}. You have {2} {1}.", price, profile.AlternateCurrencyPluralName, - (int)profile.AlternateCurrencyAmount); + ResolveAlternateCurrencyAmount(profile)); } int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0; @@ -1576,11 +1633,17 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag return; uint quantity = ResolveBuyQuantity(shopItem); - _itemInteraction.TryBuy( - _vendor.VendorId, - shopItem.ItemGuid, - (int)quantity, - _vendor.Profile.AlternateCurrencyWcid); + VendorShopProfile profile = _vendor.Profile; + if (_itemInteraction.TryBuy( + _vendor.VendorId, + shopItem.ItemGuid, + (int)quantity, + profile.AlternateCurrencyWcid)) + { + RecordAlternateCurrencyPurchase( + profile, + ComputeShopItemPrice(shopItem, (int)quantity)); + } } private bool TryFindShopItem(uint guid, out VendorShopItem shopItem) @@ -1653,6 +1716,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag (int)quantity, _vendor.Profile.AlternateCurrencyWcid)) { + RecordAlternateCurrencyPurchase( + _vendor.Profile, + ComputeShopItemPrice(shopItem, (int)quantity)); _buyStaging.Remove(shopItem.ItemGuid, BuyStagingRemovalAmount(shopItem)); } } @@ -1684,11 +1750,8 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// /// pyreal affordability — transaction total vs. purse /// (pc:204017: m_transactionValue <= m_totalValue). - /// alt-currency affordability — vs. held trade currency minus - /// m_last_sale (pc:204032). This session tracks no - /// m_last_sale credit yet (see the register's AP-161 residual), - /// so this uses the vendor's raw held count, retail's own - /// m_last_sale == 0 case. + /// alt-currency affordability — vs. the authoritative held trade + /// currency minus m_last_sale (pc:204032). /// container-slot capacity (pc:204053: /// containerSlotsNeeded > player.ContainersCapacity - containersUsed). /// item-slot capacity (pc:204067: the same shape for @@ -1753,7 +1816,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag return; } } - else if (transactionValue > (int)profile.AlternateCurrencyAmount) + else if (transactionValue > ResolveAlternateCurrencyAmount(profile)) { _systemMessage?.Invoke(NotEnoughMoneyMessage); return; @@ -1778,7 +1841,10 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag } if (_itemInteraction.TryBuyAll(_vendor.VendorId, items, profile.AlternateCurrencyWcid)) + { + RecordAlternateCurrencyPurchase(profile, transactionValue); _buyStaging.Clear(); + } } /// F1: the SAME per-row price formula shows, summed over every staged entry. @@ -1904,7 +1970,7 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag return string.Format( CultureInfo.InvariantCulture, "You have {0} {1}.", - (int)profile.AlternateCurrencyAmount, + ResolveAlternateCurrencyAmount(profile), profile.AlternateCurrencyPluralName); } int playerTotal = _objects.Get(_playerGuid())?.Properties.GetInt((uint)PropertyInt.CoinValue) ?? 0; @@ -1961,8 +2027,52 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// private void OnObjectMoneyChanged(ClientObject updated) { + TryResolvePendingVendorSplit(updated); if (updated.ObjectId != _playerGuid()) return; + RefreshMoneyText(); + } + + private void OnObjectAdded(ClientObject item) + { + TryResolvePendingVendorSplit(item); + if (IsCurrentAlternateCurrency(item) + && _objects.IsOwnedByObject(item.ObjectId, _playerGuid())) + { + ReconcileAlternateCurrencyInventory(); + } + } + + private void OnStackSizeUpdated(ClientObject item) + { + if (IsCurrentAlternateCurrency(item) + && _objects.IsOwnedByObject(item.ObjectId, _playerGuid())) + { + ReconcileAlternateCurrencyInventory(); + } + } + + private void OnObjectMoved(ClientObjectMove move) + { + if (move.Item is not { } item) + return; + + TryResolvePendingVendorSplit(item); + if (!IsCurrentAlternateCurrency(item)) + return; + + ReconcileAlternateCurrencyInventory(); + } + + private void ReconcileAlternateCurrencyInventory() + { + _alternateCurrencyInventoryObserved = true; + _lastAlternateCurrencyPurchase = 0; + RefreshMoneyText(); + } + + private void RefreshMoneyText() + { UpdateBuyTransactionText(); UpdateSellTransactionText(); // Post-buy gate finding (2026-08-08): the Items tab's cost sentence @@ -1972,6 +2082,57 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag RefreshSelectionDisplay(); } + private bool IsCurrentAlternateCurrency(ClientObject item) + { + uint wcid = _vendor.Profile.AlternateCurrencyWcid; + return wcid != 0u && item.WeenieClassId == wcid; + } + + private int ResolveAlternateCurrencyAmount(VendorShopProfile profile) + { + if (profile.AlternateCurrencyWcid == 0u) + return 0; + + long live = 0; + bool found = false; + foreach (ClientObject item in _objects.Objects) + { + if (item.WeenieClassId != profile.AlternateCurrencyWcid + || !_objects.IsOwnedByObject(item.ObjectId, _playerGuid())) + { + continue; + } + found = true; + live += Math.Max(1, item.StackSize); + } + + long baseline = found || _alternateCurrencyInventoryObserved + ? live + : profile.AlternateCurrencyAmount; + return (int)Math.Clamp( + baseline - _lastAlternateCurrencyPurchase, + 0L, + int.MaxValue); + } + + private void RecordAlternateCurrencyPurchase(VendorShopProfile profile, int price) + { + if (profile.AlternateCurrencyWcid == 0u || price <= 0) + return; + _lastAlternateCurrencyPurchase = price; + RefreshMoneyText(); + } + + private void ResetAlternateCurrencyTracking() + { + _lastAlternateCurrencyPurchase = 0; + uint wcid = _vendor.Profile.AlternateCurrencyWcid; + _alternateCurrencyInventoryObserved = wcid != 0u + && _objects.Objects.Any(item => + item.WeenieClassId == wcid + && _objects.IsOwnedByObject(item.ObjectId, _playerGuid())); + } + /// /// F1: port of gmVendorUI::InqListSlotCount (pc:200038-200065, /// 0x004c0c10) — see 's own doc @@ -2216,7 +2377,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag cell.SetItem(shopItem.ItemGuid, icon); cell.Selected = shopItem.ItemGuid == selectedGuid; VendorShopItem captured = shopItem; - cell.Clicked = () => _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor); + cell.Clicked = () => + _selection.Select(captured.ItemGuid, SelectionChangeSource.Vendor); + cell.DoubleClicked = () => RemoveOneBuyingUnit(captured.ItemGuid); list.AddItem(cell); } } @@ -2249,13 +2412,16 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag { SpriteResolve = list.SpriteResolve, SlotIndex = list.GetNumUIItems(), - AllowDragSource = false, + AllowDragSource = true, + SourceKind = ItemDragSource.Inventory, TooltipTextResolve = g => _objects.Get(g)?.GetTooltipDisplayName(), }; cell.SetItem(item.ObjectId, icon); cell.Selected = item.ObjectId == selectedGuid; uint captured = item.ObjectId; - cell.Clicked = () => _selection.Select(captured, SelectionChangeSource.Vendor); + cell.Clicked = () => + _selection.Select(captured, SelectionChangeSource.Vendor); + cell.DoubleClicked = () => RemoveSellingEntry(captured); list.AddItem(cell); } } @@ -2267,15 +2433,62 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag // gate, pc:204229-204246) ────────────────────────────────────────────── /// - /// The Selling list never sources a drag of its own — every staged cell - /// sets AllowDragSource = false (F3, Slice 6 review), the same - /// non-drag-source convention every vendor row uses — so - /// 's drag-lift dispatch (which routes to the - /// SOURCE list's own registered handler) can never actually reach this - /// method in practice. Implemented as a no-op for interface completeness. + /// Retail RecvNotice_ItemListBeginDrag @ 0x004C4380: lifting an + /// already-staged Selling row removes it in full. A partial toolbar split + /// is not applied to this list; retail prints the literal refusal and + /// restores the slider to its maximum. /// public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { + if (!ReferenceEquals(sourceList, _sellingList) || payload.ObjId == 0u) + return; + + _selection.Select(payload.ObjId, SelectionChangeSource.Vendor); + RemoveSellingEntry(payload.ObjId, reportRemoval: false); + if (_objects.Get(payload.ObjId) is not { } item) + return; + + uint fullStack = (uint)Math.Max(1, item.StackSize); + uint selected = _splitQuantity.GetObjectSplitSize( + payload.ObjId, + _selection.SelectedObjectId ?? 0u, + fullStack); + if (selected < fullStack) + { + _itemInteraction.ReportClientLocal( + "You cannot split items from this panel"); + _splitQuantity.Reset(fullStack); + } + } + + private void RemoveOneBuyingUnit(uint itemGuid) + { + if (!_buyStaging.TryGet(itemGuid, out _)) + return; + _selection.Select(itemGuid, SelectionChangeSource.Vendor); + ReportShoppingListRemoval(itemGuid); + _buyStaging.Remove(itemGuid, 1); + } + + private void RemoveSellingEntry(uint itemGuid, bool reportRemoval = true) + { + if (!_sellStaging.TryGet(itemGuid, out _)) + return; + _selection.Select(itemGuid, SelectionChangeSource.Vendor); + if (reportRemoval) + ReportShoppingListRemoval(itemGuid); + _sellStaging.Remove(itemGuid, -1); + } + + private void ReportShoppingListRemoval(uint itemGuid) + { + string? name = _objects.Get(itemGuid)?.GetAppropriateName(); + if (string.IsNullOrWhiteSpace(name)) + name = _vendor.Items.FirstOrDefault(item => item.ItemGuid == itemGuid).Name; + if (string.IsNullOrWhiteSpace(name)) + name = "that item"; + _itemInteraction.ReportClientLocal( + $"Removing {name} from shopping list"); } public ItemDragAcceptance OnDragOver( @@ -2335,8 +2548,9 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// silent=0, showing a rejection string) chained into /// VendorSellUI::AddItemToSell (pc:203546-203567) on /// success: auto-switch to the "Selling" tab, globally select the - /// dropped item, stage it. Purely client-local — sends nothing to the - /// server, matching the Buying tab's "Add to List". + /// dropped item, and stage it. For a partial stack retail first calls + /// AttemptToPlaceInContainer, stages the source as a temporary + /// row, then replaces that row when the new split object arrives. /// public void HandleDropRelease( UiItemList targetList, @@ -2356,6 +2570,29 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag ShowTab(VendorPanelTab.Selling); _selection.Select(payload.ObjId, SelectionChangeSource.Vendor); + + ClientObject item = _objects.Get(payload.ObjId)!; + int fullStack = Math.Max(1, item.StackSize); + if (quantity < fullStack) + { + _pendingVendorSplit = new PendingVendorSplit( + payload.ObjId, + item.WeenieClassId, + quantity); + if (!_itemInteraction.TrySplitToContainer( + payload.ObjId, + item.ContainerId, + 0u, + (uint)quantity)) + { + _pendingVendorSplit = null; + _systemMessage?.Invoke("Cannot split the stack to sell it"); + return; + } + + string name = string.IsNullOrWhiteSpace(item.Name) ? "item" : item.Name; + _systemMessage?.Invoke($"Splitting the {name} before selling them"); + } _sellStaging.Add(payload.ObjId, quantity); } @@ -2366,18 +2603,12 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag /// is the staged quantity a successful drop /// would use. /// - /// F6 (Slice 6b/6c review, byte-verified): this is ALWAYS the item's - /// FULL current stack — retail's VendorSellUI::AddItemToSell - /// (pc:203546-203567) stages via gmVendorUI::AddItem(..., - /// itemGuid, -1, ...), a LITERAL -1 "full stack" sentinel - /// argument, never a slider read. A prior version of this port read the - /// LIVE split-quantity slider here instead (the Slice 6b/6c research - /// doc's Q4 section had flagged this exact source as an unverified - /// inferred analogy to the Buying tab's AddToBuyList) — that - /// inference is now known WRONG: Sell staging has no partial-quantity - /// feature in retail at all, unlike Buy. See - /// VendorStagingList.Add's own doc comment for the Buy side's - /// (genuinely slider-driven) contrast. + /// Retail's full-stack branch does pass the literal -1 sentinel + /// to AddItemToSell. The enclosing + /// VendorSellUI::AcceptDragObject, however, first compares the + /// live split slider with the maximum and creates a separate stack when + /// they differ. Therefore the quantity exposed here is the live slider + /// amount for stackables, not always the source's full count. /// /// private VendorSellRejection EvaluateSellAcceptability(uint itemGuid, out int quantity) @@ -2401,10 +2632,44 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag item.PublicWeenieBitfield ?? 0u); if (rejection == VendorSellRejection.None) - quantity = (int)Math.Max(1, item.StackSize); + { + uint fullStack = (uint)Math.Max(1, item.StackSize); + quantity = (int)_splitQuantity.GetObjectSplitSize( + itemGuid, + _selection.SelectedObjectId ?? 0u, + fullStack); + } return rejection; } + private void TryResolvePendingVendorSplit(ClientObject item) + { + if (_pendingVendorSplit is not { } pending + || item.ObjectId == pending.SourceGuid + || item.WeenieClassId != pending.WeenieClassId + || item.StackSize != pending.Quantity + || !_objects.IsOwnedByObject(item.ObjectId, _playerGuid())) + { + return; + } + + if (_sellStaging.Replace(pending.SourceGuid, item.ObjectId)) + _pendingVendorSplit = null; + } + + private void OnInventoryRequestFailed(PendingInventoryRequest request, uint _) + { + if (_pendingVendorSplit is not { } pending + || request.Kind != InventoryRequestKind.SplitToContainer + || request.ItemId != pending.SourceGuid) + { + return; + } + + _sellStaging.Remove(pending.SourceGuid, -1); + _pendingVendorSplit = null; + } + /// /// G4/Slice 6b: port of retail's close/pushpin button handler — /// gmVendorUI::HandleButtonClicks's 0x100000d6 case @@ -2564,8 +2829,12 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag _disposed = true; _vendor.Changed -= OnVendorChanged; _selection.Changed -= OnSelectionTransition; + _objects.ObjectAdded -= OnObjectAdded; _objects.ObjectRemoved -= OnObjectRemoved; _objects.ObjectUpdated -= OnObjectMoneyChanged; + _objects.StackSizeUpdated -= OnStackSizeUpdated; + _objects.ObjectMoved -= OnObjectMoved; + _itemInteraction.RuntimeTransactions.Inventory.RequestFailed -= OnInventoryRequestFailed; _itemInteraction.StateChanged -= OnInteractionStateChanged; _splitQuantity.Changed -= OnSplitQuantityChanged; _buyStaging.Changed -= RebuildBuyingList; @@ -2581,6 +2850,17 @@ public sealed class VendorUiController : IRetainedPanelController, IItemListDrag _typeMenu.OnSelect = null; _typeMenu.ButtonLabelProvider = null; _itemList.ExamineItemRequested = null; + _itemList.PrimaryItemPressed = null; + if (_buyingList is not null) + { + _buyingList.ExamineItemRequested = null; + _buyingList.PrimaryItemPressed = null; + } + if (_sellingList is not null) + { + _sellingList.ExamineItemRequested = null; + _sellingList.PrimaryItemPressed = null; + } if (_close is not null) _close.OnClick = null; if (_buyButton is not null) diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index f8b77788..8ac69a8e 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -12,6 +12,7 @@ using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net; using AcDream.Core.Net.Messages; +using AcDream.Core.Properties; using AcDream.Core.Selection; using AcDream.Core.Spells; using AcDream.Runtime; @@ -441,7 +442,8 @@ public sealed record VendorRuntimeBindings( /// Campaign OP slice OP8: the Configure Keyboard screen's live read/write seam — /// the ONE live (Bindings for reads, /// SetBindings+BeginCapture for writes/capture) plus the portable -/// keybinds.json path (D4 — no .keymap file interchange). Null +/// keybinds.json mirror path. Retail *.keymap profiles live in +/// Documents/Asheron's Call and the selected profile is reloaded at startup. Null /// (headless/no-window hosts, or before the graphical /// input stack finishes constructing) degrades to "Configure Keyboard has no /// live effect" exactly like every other null-dependency Options-panel seam. @@ -464,11 +466,10 @@ public sealed record KeyboardRuntimeBindings( /// (RecvNotice_CloseDialog@0x004ed760 case 1) retail queues UI mode /// 0x10000009 (gmEpilogueUI) rather than exiting immediately — /// out of scope here. This is a plain host action, not a generation-gated -/// Runtime command: it is the SAME window-close path -/// GameplayWindowCommands/IGameplayWindowCommands.Close already -/// use for the in-world Escape fallback (d.Window.Close at -/// composition), so status events disconnected/exited still -/// fire through GameWindow.OnClosingCompleteShutdown. +/// Runtime command. It closes through d.Window.Close, so status events +/// disconnected/exited still fire through +/// GameWindow.OnClosingCompleteShutdown. In-world Escape does +/// not use this path; retail clears selection or toggles Gameplay Options. /// public sealed record CharacterSelectionRuntimeBindings( Func View, @@ -529,7 +530,8 @@ public sealed record RetailUiRuntimeBindings( KeyboardRuntimeBindings? Keyboard = null, CharacterSelectionRuntimeBindings? CharacterSelection = null, // Campaign CC slice CC4: sibling of CharacterSelection above. - CharacterCreationRuntimeBindings? CharacterCreation = null); + CharacterCreationRuntimeBindings? CharacterCreation = null, + Action? CaptureScreenshot = null); /// /// Composition owner for the production retained gameplay UI. GameWindow supplies @@ -742,6 +744,7 @@ public sealed class RetailUiRuntime : IDisposable public VendorUiController? VendorController { get; private set; } public OptionsPanelController? OptionsPanelController { get; private set; } public SocialPanelController? SocialPanelController { get; private set; } + private CharacterStatController.Binding? _characterStatBinding; /// Campaign QT slice QT5 — the three-tab Journal panel. public Layout.JournalPanelController? JournalPanelController { get; private set; } @@ -1006,56 +1009,278 @@ public sealed class RetailUiRuntime : IDisposable { if (SpellcastingUiController?.Handle(action) == true) return true; - if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleSpellbookPanel) + + switch (action) { - OpenSpellbook(SpellbookWindowPage.Spells); - return true; - } - if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleSpellComponentsPanel) - { - OpenSpellbook(SpellbookWindowPage.Components); - return true; - } - // Campaign FA slice FA3: F3/F4 — keyboard-only open paths (lane A - // §6.1: neither action authors a toolbar button). Both share the - // one social panel (RetailPanelCatalog.SocialPanel) and switch to - // their own tab; the panel participates in the SAME gmPanelUI - // one-active-panel exclusivity every sibling panel gets from - // RetailPanelUiController.RegisterMainPanel. - if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleAllegiancePanel) - { - OpenSocialPanel(showAllegiance: true); - return true; - } - if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleFellowshipPanel) - { - OpenSocialPanel(showAllegiance: false); - return true; + case AcDream.UI.Abstractions.Input.InputAction.CaptureScreenshot: + _bindings.CaptureScreenshot?.Invoke(); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleHelp: + // EoR delegates this to the separately shipped ACHelpPlugin. + // That binary is not part of acdream; consume the retail action + // and report the unavailable external surface honestly. + _bindings.Options.DisplaySystemMessage( + "In-game help is unavailable because the retail help plugin is not installed."); + return true; + case AcDream.UI.Abstractions.Input.InputAction.TogglePluginManager: + _bindings.Options.DisplaySystemMessage( + "The retail plugin manager is not available in acdream."); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleAbuseReportingPanel: + _bindings.Options.DisplaySystemMessage(OptionsPanelText.ReportAbuseUnavailable); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleUrgentAssistancePanel: + _bindings.Options.DisplaySystemMessage(OptionsPanelText.UrgentAssistanceUnavailable); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ChatReply: + _chatWindowController?.StartReply(_bindings.Chat.ViewModel.LastIncomingTellSender); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ChatMonarchReply: + _chatWindowController?.StartReply(_bindings.Chat.ViewModel.LastMonarchSender); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ChatPatronReply: + _chatWindowController?.StartReply(_bindings.Chat.ViewModel.LastPatronSender); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ChatStartCommand: + _chatWindowController?.StartCommand(); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ChatTellToSelected: + { + uint selected = _bindings.Toolbar.Selection.SelectedObjectId ?? 0u; + if (selected is >= 0x50000001u and <= 0x6FFFFFFFu) + { + string? name = _bindings.Toolbar.ResolveName(selected); + if (!string.IsNullOrEmpty(name)) + _chatWindowController?.StartTell(name); + } + return true; + } + case AcDream.UI.Abstractions.Input.InputAction.EnterChatMode: + _chatWindowController?.EnterChatMode( + _bindings.Keyboard?.Dispatcher?.CurrentPhysicalChord); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleChatEntry: + _chatWindowController?.ToggleChatEntry( + _bindings.Keyboard?.Dispatcher?.CurrentPhysicalChord); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleCharacterInfoPanel: + ToggleWindow(WindowNames.CharacterInformation); + return true; + case AcDream.UI.Abstractions.Input.InputAction.TogglePositiveMagicPanel: + ToggleWindow(WindowNames.PositiveEffects); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleNegativeMagicPanel: + ToggleWindow(WindowNames.NegativeEffects); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleLinkStatusPanel: + ToggleWindow(WindowNames.LinkStatus); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleVitaePanel: + ToggleWindow(WindowNames.Vitae); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleSocialPanel: + ToggleWindow(WindowNames.SocialPanel); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleAllegiancePanel: + OpenSocialPanel(SocialPanelPage.Allegiance); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleFellowshipPanel: + OpenSocialPanel(SocialPanelPage.Fellowship); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleFriendsPage: + OpenSocialPanel(SocialPanelPage.Friends); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleSpellManagementPanel: + ToggleWindow(WindowNames.Spellbook); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleSpellbookPanel: + OpenSpellbook(SpellbookWindowPage.Spells); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleSpellComponentsPanel: + OpenSpellbook(SpellbookWindowPage.Components); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleCharacterDetailPanel: + ToggleWindow(WindowNames.Character); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleAttributesPanel: + OpenCharacterPanel(CharacterStatController.CharacterStatTab.Attributes); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleSkillsPanel: + OpenCharacterPanel(CharacterStatController.CharacterStatTab.Skills); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleCharacterTitlesPage: + OpenCharacterPanel(CharacterStatController.CharacterStatTab.Titles); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleWorldPanel: + ToggleWindow(WindowNames.MapHouse); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleMapPage: + OpenWorldPanel(showHouse: false); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleHousePage: + OpenWorldPanel(showHouse: true); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleOptionsPanel: + ToggleWindow(WindowNames.Options); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleGameplayOptionsPage: + OpenOptionsPage(OptionsPanelPage.Gameplay); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleCharacterSettingsPage: + OpenOptionsPage(OptionsPanelPage.Character); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleConfigurationPage: + OpenOptionsPage(OptionsPanelPage.Configuration); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleCompass: + Host.ToggleWindow(WindowNames.Radar); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleKeyboardConfiguration: + ToggleWindow(WindowNames.KeyboardConfig); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleQuestJournalPage: + OpenJournalPanel(JournalPanelPage.Notes); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleQuestDetailPanel: + // EoR's quest-detail action addresses the quest-management + // surface. The current authored Journal host's server-backed + // Contracts page is that surface in acdream. + OpenJournalPanel(JournalPanelPage.Contracts); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleJournalPageList: + OpenJournalPanel(JournalPanelPage.PageList); + return true; + case AcDream.UI.Abstractions.Input.InputAction.ToggleContractsPage: + OpenJournalPanel(JournalPanelPage.Contracts); + return true; } + return ToolbarInputController?.Handle(action) == true; } + private void OpenCharacterPanel(CharacterStatController.CharacterStatTab tab) + { + bool visible = Host.IsWindowVisible(WindowNames.Character); + bool onTargetTab = _characterStatBinding?.CurrentTab() == tab; + if (visible && onTargetTab) + { + CloseWindow(WindowNames.Character); + return; + } + + _characterStatBinding?.ShowTab(tab); + _panelUi.SetPanelVisibility(RetailPanelCatalog.Character, visible: true); + } + + private void OpenWorldPanel(bool showHouse) + { + bool visible = Host.IsWindowVisible(WindowNames.MapHouse); + bool onTargetTab = showHouse + ? MapHousePanelController?.IsShowingHouse == true + : MapHousePanelController?.IsShowingMap == true; + if (visible && onTargetTab) + { + CloseWindow(WindowNames.MapHouse); + return; + } + + if (showHouse) + MapHousePanelController?.ShowHouse(); + else + MapHousePanelController?.ShowMap(); + _panelUi.SetPanelVisibility(RetailPanelCatalog.MapHouse, visible: true); + } + + private enum OptionsPanelPage { Gameplay, Character, Configuration } + + private void OpenOptionsPage(OptionsPanelPage page) + { + bool visible = Host.IsWindowVisible(WindowNames.Options); + bool onTargetTab = page switch + { + OptionsPanelPage.Gameplay => OptionsPanelController?.IsShowingGameplay == true, + OptionsPanelPage.Character => OptionsPanelController?.IsShowingCharacter == true, + OptionsPanelPage.Configuration => OptionsPanelController?.IsShowingConfiguration == true, + _ => false, + }; + if (visible && onTargetTab) + { + CloseWindow(WindowNames.Options); + return; + } + + switch (page) + { + case OptionsPanelPage.Gameplay: OptionsPanelController?.ShowGameplay(); break; + case OptionsPanelPage.Character: OptionsPanelController?.ShowCharacter(); break; + case OptionsPanelPage.Configuration: OptionsPanelController?.ShowConfiguration(); break; + } + _panelUi.SetPanelVisibility(RetailPanelCatalog.Options, visible: true); + } + + /// + /// Retail Escape's final fallback: toggle action 0x1000001B, + /// whose installed-DAT ActionMap label is "Show/Hide Gameplay Options + /// Page". Reuses the authored Options tab and panel owners. + /// + public void ToggleGameplayOptionsPage() + => OpenOptionsPage(OptionsPanelPage.Gameplay); + + /// Semantic/rebound form of retail Enter/Tab chat activation. + public void FocusChatEntry() + { + if (Host.Root.DefaultTextInput is { } input) + Host.Root.SetKeyboardFocus(input); + } + + /// + /// Shift+Escape's retail LOGOUT action: no confirmation dialog; the + /// normal grounded/airborne/no-player gate still applies. + /// + public void LogOutCharacter() => EndCharacterSessionWithRetailGates(); + /// Shared F3/F4 handler — same "toggle closes on a repeat press /// of the SAME tab, otherwise show + switch" shape as . - private void OpenSocialPanel(bool showAllegiance) + private enum SocialPanelPage { Friends, Allegiance, Fellowship } + + private void OpenSocialPanel(SocialPanelPage page) { bool visible = Host.IsWindowVisible(WindowNames.SocialPanel); - bool onTargetTab = showAllegiance - ? SocialPanelController?.IsShowingAllegiance == true - : SocialPanelController?.IsShowingFellowship == true; + bool onTargetTab = page switch + { + SocialPanelPage.Friends => SocialPanelController?.IsShowingFriends == true, + SocialPanelPage.Allegiance => SocialPanelController?.IsShowingAllegiance == true, + SocialPanelPage.Fellowship => SocialPanelController?.IsShowingFellowship == true, + _ => false, + }; if (visible && onTargetTab) { CloseWindow(WindowNames.SocialPanel); return; } - if (showAllegiance) - SocialPanelController?.ShowAllegiance(); - else - SocialPanelController?.ShowFellowship(); + switch (page) + { + case SocialPanelPage.Friends: SocialPanelController?.ShowFriends(); break; + case SocialPanelPage.Allegiance: SocialPanelController?.ShowAllegiance(); break; + case SocialPanelPage.Fellowship: SocialPanelController?.ShowFellowship(); break; + } _panelUi.SetPanelVisibility(RetailPanelCatalog.SocialPanel, visible: true); } + private enum JournalPanelPage { Contracts, Notes, PageList } + + private void OpenJournalPanel(JournalPanelPage page) + { + switch (page) + { + case JournalPanelPage.Contracts: JournalPanelController?.ShowContracts(); break; + case JournalPanelPage.Notes: JournalPanelController?.ShowNotes(); break; + case JournalPanelPage.PageList: JournalPanelController?.ShowPageList(); break; + } + _panelUi.SetPanelVisibility(RetailPanelCatalog.Journal, visible: true); + } + private void OpenSpellbook(SpellbookWindowPage page) { bool visible = Host.IsWindowVisible(WindowNames.Spellbook); @@ -1853,7 +2078,10 @@ public sealed class RetailUiRuntime : IDisposable StackSplitQuantity, handler => b.Objects.ObjectUpdated += handler, handler => b.Objects.ObjectUpdated -= handler, - b.IsVendorSplitExempt); + b.IsVendorSplitExempt, + isCoinstack: guid => b.Objects.Get(guid)?.WeenieClassId == 273u, + coinTotal: () => b.Objects.Get(b.PlayerGuid())?.Properties.GetInt( + (uint)PropertyInt.CoinValue) ?? 0); UiElement root = layout.Root; RetailWindowHandle handle = RetailWindowFrame.Mount( @@ -3072,12 +3300,88 @@ public sealed class RetailUiRuntime : IDisposable string unmappedPath = UnmappedKeyBindingsPath(keyboard.KeyBindingsFilePath); var unmapped = RetailUnmappedKeyBindings.LoadOrEmpty(unmappedPath); + var keymaps = new RetailKeymapProfileStore(keyboard.KeyBindingsFilePath); - // ID_KeyMapCantOverwriteReadOnlyKeymap_Label — table 0x23000004, byte- - // verified 2026-08-11 (live probe): "Could not overwrite ". Falls back - // to silence (no invented English) if the DAT string is ever missing. - string? refusalText = strings.Resolve( - 0x23000004u, DatStringResolver.ComputeHash("ID_KeyMapCantOverwriteReadOnlyKeymap_Label")); + string? ResolveKeymapTemplate(string key, string fileName) + { + // The localized templates use one named filename variable. Keep + // the common retail spellings populated; ResolveTemplate selects + // only the hash actually authored by the DAT entry. + var variables = new Dictionary + { + [DatStringResolver.ComputeHash("LABEL")] = fileName, + [DatStringResolver.ComputeHash("KEYMAP")] = fileName, + [DatStringResolver.ComputeHash("FILENAME")] = fileName, + [DatStringResolver.ComputeHash("NAME")] = fileName, + [DatStringResolver.ComputeHash("VALUE")] = fileName, + }; + lock (_bindings.Assets.DatLock) + return strings.ResolveTemplate(0x23000004u, key, variables); + } + + void ShowKeymapMessage(string? message) + { + if (!string.IsNullOrWhiteSpace(message) && DialogFactory is not null) + DialogFactory.MakeMessage(message, queueKey: 0x10000001u, priority: true); + } + + void SaveMirrors() + { + dispatcher.Bindings.SaveToFile(keyboard.KeyBindingsFilePath); + unmapped.SaveToFile(unmappedPath); + } + + void HandleSaveResult( + RetailKeymapSaveResult result, + string requestedName, + Action onSaved) + { + switch (result.Status) + { + case RetailKeymapSaveStatus.Saved: + try + { + SaveMirrors(); + } + catch (Exception failure) + { + Console.WriteLine($"keyboard config: JSON mirror save failed: {failure.Message}"); + } + // The retail .keymap is the canonical save. A failure in + // acdream's compatibility JSON mirror must not leave the + // authored filename label showing the previous profile. + onSaved(); + return; + + case RetailKeymapSaveStatus.Exists: + string? overwrite = ResolveKeymapTemplate( + "ID_KeyMapOverwriteKeymap_Label", result.FileName); + if (overwrite is null || DialogFactory is null) return; + DialogFactory.MakeConfirmation( + overwrite, + data => + { + if (!data.GetBoolean(RetailDialogProperty.ConfirmationResult)) return; + HandleSaveResult( + keymaps.Save(requestedName, dispatcher.Bindings, overwrite: true), + requestedName, + onSaved); + }, + queueKey: 0x10000001u, + priority: true); + return; + + case RetailKeymapSaveStatus.ReadOnly: + ShowKeymapMessage(ResolveKeymapTemplate( + "ID_KeyMapCantOverwriteReadOnlyKeymap_Label", result.FileName)); + return; + + default: + Console.WriteLine( + $"keyboard config: keymap save failed ({result.Status}): {result.Error}"); + return; + } + } Layout.KeyboardConfigController? controller = Layout.KeyboardConfigController.Bind( layout, @@ -3120,15 +3424,12 @@ public sealed class RetailUiRuntime : IDisposable chord => onResult(chord == default ? null : chord)), Save: () => { - // S3 (2026-08-11 review): match the existing keybinds.json - // writer's own discipline (RuntimeKeyBindingTarget.Apply) — - // an IO failure is reported, not thrown out of UiButton.OnClick - // into the input/render loop, and does not roll back the - // already-accepted live binding. try { - dispatcher.Bindings.SaveToFile(keyboard.KeyBindingsFilePath); - unmapped.SaveToFile(unmappedPath); + HandleSaveResult( + keymaps.SaveActive(dispatcher.Bindings), + keymaps.CurrentFileName, + static () => { }); } catch (Exception failure) { @@ -3136,11 +3437,23 @@ public sealed class RetailUiRuntime : IDisposable } }, Toggle: () => ToggleWindow(WindowNames.KeyboardConfig), - DisplaySystemMessage: text => + ResolveTemplate: (key, variables) => { - if (!string.IsNullOrEmpty(text)) _bindings.Options.DisplaySystemMessage(text); + lock (_bindings.Assets.DatLock) + { + return strings.ResolveTemplate(0x23000004u, key, variables); + } + }, + // UIOption_ActionKeyMap::OpenCantOverwriteBindingDialog + // @0x00489300: type 3, keyboard queue 0x10000001, priority. + ShowMessage: message => + { + if (DialogFactory is null) return; + DialogFactory.MakeMessage( + message, + queueKey: 0x10000001u, + priority: true); }, - NonBindableRefusalText: refusalText ?? string.Empty, // M3 (2026-08-11 review): retail's OpenOverwriteBindingDialog — // confirm through the SAME RetailDialogFactory/MakeConfirmation // seam GameplayConfirmationController already uses, before @@ -3153,7 +3466,9 @@ public sealed class RetailUiRuntime : IDisposable if (DialogFactory is null) { onResult(false); return; } DialogFactory.MakeConfirmation( message, - data => onResult(data.GetBoolean(RetailDialogProperty.ConfirmationResult))); + data => onResult(data.GetBoolean(RetailDialogProperty.ConfirmationResult)), + queueKey: 0x10000001u, + priority: true); }, // OP8 re-gate (2026-08-14): retail's capture-instruction dialog // (InitiateBinding @ 0x004899D0 → OpenMapWarnDialog @ 0x00488A00): @@ -3179,7 +3494,10 @@ public sealed class RetailUiRuntime : IDisposable // `text` arrives with real line breaks. try { - return DialogFactory.MakeWait(text, queueKey: 0x10000001u); + return DialogFactory.MakeWait( + text, + queueKey: 0x10000001u, + priority: true); } catch (Exception failure) { @@ -3196,7 +3514,64 @@ public sealed class RetailUiRuntime : IDisposable } }, CloseCaptureInstructions: context => - DialogFactory?.CloseDialog(context)), + DialogFactory?.CloseDialog(context), + CurrentKeymapFilename: () => keymaps.CurrentFileName, + OpenLoadKeymap: onLoaded => + { + if (DialogFactory is null) return; + IReadOnlyList files = keymaps.ListFiles(); + int selected = files + .Select(static (name, index) => (name, index)) + .FirstOrDefault( + pair => string.Equals( + pair.name, + keymaps.CurrentFileName, + StringComparison.OrdinalIgnoreCase), + (name: string.Empty, index: 0)).index; + DialogFactory.MakeConfirmationMenu( + files, + selected, + data => + { + int choice = data.GetInt32(RetailDialogProperty.MenuSelection, -1); + if (choice < 0 || choice >= files.Count) return; + if (!keymaps.TryLoad( + files[choice], + dispatcher.Bindings, + out KeyBindings loaded, + out string? error)) + { + Console.WriteLine($"keyboard config: keymap load failed: {error}"); + return; + } + dispatcher.SetBindings(loaded); + try { SaveMirrors(); } + catch (Exception failure) + { + Console.WriteLine( + $"keyboard config: loaded profile JSON mirror failed: {failure.Message}"); + } + onLoaded(); + }, + queueKey: 0x10000001u); + }, + OpenSaveKeymap: onSaved => + { + if (DialogFactory is null) return; + DialogFactory.MakeConfirmationTextInput( + string.Empty, + data => + { + string name = data.GetString(RetailDialogProperty.TextInputResult) + ?? string.Empty; + if (name.Length == 0) return; + HandleSaveResult( + keymaps.Save(name, dispatcher.Bindings, overwrite: false), + name, + onSaved); + }, + queueKey: 0x10000001u); + }), resolveTemplateFont: (templateLayoutId, templateElementId) => { lock (_bindings.Assets.DatLock) @@ -4073,7 +4448,7 @@ public sealed class RetailUiRuntime : IDisposable lock (_bindings.Assets.DatLock) return RetailDataIdResolver.Resolve(_bindings.Assets.Dats, enumValue, category); } - Action refreshRows = CharacterStatController.Bind( + _characterStatBinding = CharacterStatController.Bind( layout, () => currentSheet, _bindings.Assets.DefaultFont, @@ -4090,7 +4465,7 @@ public sealed class RetailUiRuntime : IDisposable _characterSheetSubscription = provider.SubscribeChanged(() => { currentSheet = provider.BuildSheet(); - refreshRows(); + _characterStatBinding?.Refresh(); }); // CT3 (2026-08-24): the Titles page's row template lives in a diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index be9e107f..3f0752a2 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -184,6 +184,12 @@ public sealed class UiRoot : UiElement /// Widget currently receiving keyboard events. public UiElement? KeyboardFocus { get; private set; } + // The dispatcher is attached before retained UI. A semantic binding can + // therefore focus chat before this tree receives the same native key. + // Suppress that exact key through KeyChar/KeyUp so it cannot immediately + // submit the newly-focused field or insert a rebound printable key. + private int? _suppressedPhysicalKey; + /// The edit control activated by Tab/Enter when nothing is focused — retail's /// chat input "write mode" toggle. Set by the host once the chat window is built. public UiElement? DefaultTextInput { get; set; } @@ -497,7 +503,18 @@ public sealed class UiRoot : UiElement internal void OnSubtreeRemoving(UiElement subtree) { - ClearSubtreeOwnership(subtree); + // Inventory/external-container lists rebuild procedurally when an + // authoritative object update arrives. That rebuild removes each old + // UIItem before adding its replacement. Once BeginDrag has promoted + // the gesture, however, retail's UIElementManager owns a separate + // root-level drag element (StartDragandDrop @ 0x0045E040) and transfers + // mouse capture to it; the source list cell is no longer the gesture's + // lifetime owner. Our drag ghost is likewise snapshotted/root-owned, + // so preserve it when the exact source leaf is replaced mid-drag and + // transfer capture to this root. Removing a containing subtree (window + // teardown) still cancels normally. + bool replacingActiveDragSource = ReferenceEquals(subtree, DragSource); + ClearSubtreeOwnership(subtree, preserveDetachedDrag: replacingActiveDragSource); WindowManager.OnSubtreeRemoving(subtree); } @@ -511,13 +528,16 @@ public sealed class UiRoot : UiElement internal void OnElementVisibilityChanged(UiElement element, bool visible) => ElementVisibilityChanged?.Invoke(element, visible); - internal void ClearSubtreeOwnership(UiElement subtree) + internal void ClearSubtreeOwnership(UiElement subtree, bool preserveDetachedDrag = false) { if (IsWithinSubtree(KeyboardFocus, subtree)) SetKeyboardFocus(null); if (IsWithinSubtree(Captured, subtree)) { - ReleaseCapture(); + if (preserveDetachedDrag && ReferenceEquals(Captured, DragSource)) + SetCapture(this); + else + ReleaseCapture(); _dragCandidate = false; } if (IsWithinSubtree(DefaultTextInput, subtree)) @@ -527,10 +547,13 @@ public sealed class UiRoot : UiElement if (IsWithinSubtree(DragSource, subtree)) { DragSource?.SetDragSourceActive(false, DragPayload); - DragSource = null; - DragPayload = null; - _dragGhost = null; - _dragCandidate = false; + if (!preserveDetachedDrag) + { + DragSource = null; + DragPayload = null; + _dragGhost = null; + _dragCandidate = false; + } } if (IsWithinSubtree(_hoverWidget, subtree)) { @@ -1090,13 +1113,15 @@ public sealed class UiRoot : UiElement public void OnKeyDown(int vk, uint lparam = 0) { + if (_suppressedPhysicalKey == vk) + return; + // Nothing focused yet: Tab or Enter enters "write mode" by focusing the chat // input (retail's chat-activation hotkeys). Consumed so the same press doesn't // also fall through to a game hotkey. if (KeyboardFocus is null && DefaultTextInput is not null && (vk == (int)Silk.NET.Input.Key.Tab - || vk == (int)Silk.NET.Input.Key.Enter - || vk == (int)Silk.NET.Input.Key.KeypadEnter)) + || vk == (int)Silk.NET.Input.Key.Enter)) { SetKeyboardFocus(DefaultTextInput); return; @@ -1125,6 +1150,11 @@ public sealed class UiRoot : UiElement public void OnKeyUp(int vk, uint lparam = 0) { + if (_suppressedPhysicalKey == vk) + { + _suppressedPhysicalKey = null; + return; + } if (KeyboardFocus is not null) { var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.KeyUp, @@ -1136,12 +1166,18 @@ public sealed class UiRoot : UiElement public void OnChar(int codepoint) { + if (_suppressedPhysicalKey is not null) + return; if (KeyboardFocus is null || !KeyboardFocus.IsEditControl) return; var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.Char, Data0: codepoint); BubbleEvent(KeyboardFocus, in e); } + /// Suppress the raw retained-UI tail of a semantic key action. + public void SuppressPhysicalKeyUntilRelease(Silk.NET.Input.Key key) + => _suppressedPhysicalKey = (int)key; + // ── Focus + capture ───────────────────────────────────────────────── public void SetKeyboardFocus(UiElement? e) diff --git a/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs b/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs index 4ee9d358..35ff578d 100644 --- a/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs +++ b/src/AcDream.Core.Net/Messages/ClientCommandRequests.cs @@ -24,6 +24,7 @@ public static class ClientCommandRequests public const uint SetAfkModeOpcode = 0x000Fu; public const uint SetAfkMessageOpcode = 0x0010u; public const uint EmoteOpcode = 0x01DFu; + public const uint SoulEmoteOpcode = 0x01E1u; public const uint AddFriendOpcode = 0x0018u; public const uint AbandonContractOpcode = 0x0316u; public const uint RemoveFriendOpcode = 0x0017u; @@ -139,6 +140,10 @@ public static class ClientCommandRequests public static byte[] BuildEmote(uint sequence, string message) => BuildString(sequence, EmoteOpcode, message); + // CM_Communication::Event_SoulEmote @ 0x006A4500. + public static byte[] BuildSoulEmote(uint sequence, string message) => + BuildString(sequence, SoulEmoteOpcode, message); + // CM_Social::Event_AddFriend/RemoveFriend/ClearFriends // @ 0x006A5C10 / 0x006A5650 / 0x006A55C0. public static byte[] BuildAddFriend(uint sequence, string name) => diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 35f5888e..08efe5c0 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -2681,6 +2681,13 @@ public sealed class WorldSession : IDisposable SendGameAction(ClientCommandRequests.BuildEmote(seq, message)); } + public void SendSoulEmote(string message) + { + ArgumentNullException.ThrowIfNull(message); + uint seq = NextGameActionSequence(); + SendGameAction(ClientCommandRequests.BuildSoulEmote(seq, message)); + } + /// /// Send retail SetSingleCharacterOption (0x0005) — toggles one character /// option. For the six ListenTo*Chat ids this is the message that diff --git a/src/AcDream.Core/Chat/ChatCommandTargetState.cs b/src/AcDream.Core/Chat/ChatCommandTargetState.cs index 47a955cc..fda55957 100644 --- a/src/AcDream.Core/Chat/ChatCommandTargetState.cs +++ b/src/AcDream.Core/Chat/ChatCommandTargetState.cs @@ -16,6 +16,8 @@ public sealed class ChatCommandTargetState : IDisposable private readonly object _gate = new(); private string? _lastIncomingTellSender; private string? _lastOutgoingTellTarget; + private string? _lastMonarchSender; + private string? _lastPatronSender; private bool _disposed; public ChatCommandTargetState(ChatLog chat) @@ -42,6 +44,26 @@ public sealed class ChatCommandTargetState : IDisposable } } + /// Most recent sender of an incoming retail @m broadcast. + public string? LastMonarchSender + { + get + { + lock (_gate) + return _lastMonarchSender; + } + } + + /// Most recent sender of an incoming retail @p broadcast. + public string? LastPatronSender + { + get + { + lock (_gate) + return _lastPatronSender; + } + } + public bool IsDisposed { get @@ -61,6 +83,8 @@ public sealed class ChatCommandTargetState : IDisposable { _lastIncomingTellSender = null; _lastOutgoingTellTarget = null; + _lastMonarchSender = null; + _lastPatronSender = null; } } @@ -77,17 +101,34 @@ public sealed class ChatCommandTargetState : IDisposable private void OnEntryAppended(ChatEntry entry) { - if (entry.Kind != ChatKind.Tell || string.IsNullOrEmpty(entry.Sender)) + if (string.IsNullOrEmpty(entry.Sender)) return; lock (_gate) { if (_disposed) return; - if (entry.SenderGuid != 0u) - _lastIncomingTellSender = entry.Sender; - else - _lastOutgoingTellTarget = entry.Sender; + if (entry.Kind == ChatKind.Tell) + { + if (entry.SenderGuid != 0u) + _lastIncomingTellSender = entry.Sender; + else + _lastOutgoingTellTarget = entry.Sender; + return; + } + + // gmCCommunicationSystem keeps independent reply targets for the + // legacy Monarch (0x4000) and Patron (0x2000) broadcasts. The + // legacy 0x0147 wire payload has no sender GUID, so its committed + // ChatEntry correctly carries zero even for an incoming speaker. + // Local channel echoes have an empty Sender and were rejected at + // the top of this method; the non-empty name is the discriminator. + if (entry.Kind != ChatKind.Channel) + return; + if (entry.ChannelId == 0x00004000u) + _lastMonarchSender = entry.Sender; + else if (entry.ChannelId == 0x00002000u) + _lastPatronSender = entry.Sender; } } } diff --git a/src/AcDream.Core/Chat/InventoryFailureMessages.cs b/src/AcDream.Core/Chat/InventoryFailureMessages.cs index c05c3269..7b7cab46 100644 --- a/src/AcDream.Core/Chat/InventoryFailureMessages.cs +++ b/src/AcDream.Core/Chat/InventoryFailureMessages.cs @@ -33,18 +33,19 @@ public static class InventoryFailureMessages string itemName, uint weenieError) { - // ServerSaysAttemptFailed's verb switch. acdream has no latched kind - // for retail's IR_MOVE ("moved") or IR_WIELD ("wielded") today — - // wields ride AutoWieldController without the single-request gate — - // so those rows are absent rather than guessed onto a wrong kind. + // ServerSaysAttemptFailed's complete verb switch. The enum values are + // named by operation rather than retail's numeric IR_* values, but the + // wording and NAME_PLURAL/NAME_APPROPRIATE choice are verbatim. string? verb = kind switch { InventoryRequestKind.Merge => "merged", InventoryRequestKind.SplitToContainer => "split", InventoryRequestKind.SplitToWorld => "split", + InventoryRequestKind.Move => "moved", InventoryRequestKind.Pickup => "picked up", InventoryRequestKind.PutInContainer => "put in the container", InventoryRequestKind.DropToWorld => "dropped", + InventoryRequestKind.Wield => "wielded", InventoryRequestKind.Give => "given", _ => null, }; diff --git a/src/AcDream.Core/Input/RetailActionMap.cs b/src/AcDream.Core/Input/RetailActionMap.cs index 9ec2083c..22f8115d 100644 --- a/src/AcDream.Core/Input/RetailActionMap.cs +++ b/src/AcDream.Core/Input/RetailActionMap.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using AcDream.Core.Content; using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; namespace AcDream.Core.Input; @@ -124,7 +125,23 @@ public sealed record RetailActionMapRow( /// The complete read result: every user-bindable ActionMap row, plus the raw /// row count read (for conformance pinning against the installed dats). -public sealed record RetailActionMapSnapshot(IReadOnlyList Rows); +public sealed record RetailActionMapSnapshot( + IReadOnlyList Rows, + IReadOnlyDictionary>? ConflictingInputMaps = null) +{ + /// + /// Retail ICIDM::FindConflictingInputMaps policy. A context always + /// conflicts with itself; cross-context conflicts exist only when the + /// DAT ActionMap.ConflictingMaps table names the other context. + /// Contexts absent from that table therefore do not conflict across maps. + /// + public bool InputMapsConflict(uint leftInputMapId, uint rightInputMapId) => + leftInputMapId == rightInputMapId + || (ConflictingInputMaps?.TryGetValue( + leftInputMapId, + out IReadOnlySet? conflicts) == true + && conflicts.Contains(rightInputMapId)); +} /// /// Retail's 19 named InputMapID -> ID_InputMap_* string-table keys @@ -220,7 +237,16 @@ public static class RetailActionMapReader } } - return new RetailActionMapSnapshot(rows); + var conflictingInputMaps = new Dictionary>(); + foreach (var entry in actionMap.ConflictingMaps) + { + InputsConflictsValue value = entry.Value; + uint inputMapId = value.InputMap != 0u ? value.InputMap : entry.Key; + conflictingInputMaps[inputMapId] = + new HashSet(value.ConflictingInputMaps); + } + + return new RetailActionMapSnapshot(rows, conflictingInputMaps); } private static void CollectDefaults( diff --git a/src/AcDream.Core/Items/ExternalContainerState.cs b/src/AcDream.Core/Items/ExternalContainerState.cs index d42eebc4..6ec69aa8 100644 --- a/src/AcDream.Core/Items/ExternalContainerState.cs +++ b/src/AcDream.Core/Items/ExternalContainerState.cs @@ -24,14 +24,31 @@ public readonly record struct ExternalContainerTransition( /// public sealed class ExternalContainerState { + private readonly HashSet _openedCorpses = []; + public uint RequestedContainerId { get; private set; } public uint CurrentContainerId { get; private set; } + public int OpenedCorpseCount => _openedCorpses.Count; public event Action? Changed; - public bool RequestOpen(uint containerId) + /// + /// Sets retail's requested ground object. When that object is a corpse, + /// this is also the exact SetGroundObject edge at which retail calls + /// ACCWeenieObject::SetCorpseOpened @ 0x0058E670. + /// + public bool RequestOpen(uint containerId, bool isCorpse = false) { - if (containerId == 0u || RequestedContainerId == containerId) + if (containerId == 0u) + return false; + + // Retail marks the corpse on the SetGroundObject edge even when the + // requested ground-object id is already current. Keep that lifetime + // fact independent from whether this call changes presentation state. + if (isCorpse) + _openedCorpses.Add(containerId); + + if (RequestedContainerId == containerId) return false; uint previous = CurrentContainerId; @@ -54,6 +71,19 @@ public sealed class ExternalContainerState return true; } + /// + /// Retail ACCWeenieObject::HasCorpseBeenOpened @ 0x0058DB70. + /// The set is session-scoped and an object's delete edge removes its id. + /// + public bool HasCorpseBeenOpened(uint objectId) + => objectId != 0u && _openedCorpses.Contains(objectId); + + /// + /// Retail ACCWeenieObject::SetCorpseDeleted @ 0x0058E6C0. + /// + public bool SetCorpseDeleted(uint objectId) + => objectId != 0u && _openedCorpses.Remove(objectId); + public bool ApplyViewContents(uint containerId) { if (containerId == 0u || containerId != RequestedContainerId) @@ -98,9 +128,12 @@ public sealed class ExternalContainerState public bool Reset() { uint previous = CurrentContainerId; - bool changed = previous != 0u || RequestedContainerId != 0u; + bool changed = previous != 0u + || RequestedContainerId != 0u + || _openedCorpses.Count != 0; CurrentContainerId = 0u; RequestedContainerId = 0u; + _openedCorpses.Clear(); var transition = new ExternalContainerTransition( ExternalContainerTransitionKind.Reset, diff --git a/src/AcDream.Core/Items/InventoryContainerPlacementPolicy.cs b/src/AcDream.Core/Items/InventoryContainerPlacementPolicy.cs new file mode 100644 index 00000000..eccb97e9 --- /dev/null +++ b/src/AcDream.Core/Items/InventoryContainerPlacementPolicy.cs @@ -0,0 +1,158 @@ +namespace AcDream.Core.Items; + +/// +/// Side-effect-free container-placement result shared by drag hover and +/// release. It ports the observable rules from +/// ItemHolder::AttemptToPlaceInContainer_IsItemLegal @ 0x005870C0, +/// AttemptToPlaceInContainer_IsContainerLegal @ 0x005879B0, and +/// WillItemFitInContainer @ 0x00587D60 that can be answered from the +/// client's public object projection. +/// +public enum InventoryContainerPlacementRejection +{ + None, + InvalidItem, + CannotMovePlayer, + CannotMoveCreature, + SourceBeingTraded, + InvalidDestination, + DestinationBeingTraded, + RecursiveContainment, + ItemCapacityFull, + ContainerCapacityFull, +} + +public static class InventoryContainerPlacementPolicy +{ + public static InventoryContainerPlacementRejection Evaluate( + ClientObjectTable objects, + uint itemId, + uint destinationId, + uint playerId) + { + ArgumentNullException.ThrowIfNull(objects); + if (itemId == 0u || objects.Get(itemId) is not { } item) + return InventoryContainerPlacementRejection.InvalidItem; + if (itemId == playerId) + return InventoryContainerPlacementRejection.CannotMovePlayer; + if ((item.Type & ItemType.Creature) != 0) + return InventoryContainerPlacementRejection.CannotMoveCreature; + if (item.TradeState == 1) + return InventoryContainerPlacementRejection.SourceBeingTraded; + ClientObject? destination = objects.Get(destinationId); + if (destinationId == 0u + || (destination is null && destinationId != playerId) + || (destination is not null && !IsContainer(destination) && destinationId != playerId)) + { + return InventoryContainerPlacementRejection.InvalidDestination; + } + if (destination?.TradeState == 1) + return InventoryContainerPlacementRejection.DestinationBeingTraded; + if (itemId == destinationId || IsContainedBy(objects, destinationId, itemId)) + return InventoryContainerPlacementRejection.RecursiveContainment; + + bool alreadyDirectlyContained = item.ContainerId == destinationId; + if (IsContainer(item)) + { + int capacity = destination?.ContainersCapacity ?? 0; + if (!alreadyDirectlyContained + && capacity > 0 + && CountContainers(objects, destinationId) >= capacity) + { + return InventoryContainerPlacementRejection.ContainerCapacityFull; + } + } + else + { + int capacity = destination?.ItemsCapacity ?? 0; + if (!alreadyDirectlyContained + && capacity > 0 + && CountItems(objects, destinationId) >= capacity) + { + return InventoryContainerPlacementRejection.ItemCapacityFull; + } + } + + return InventoryContainerPlacementRejection.None; + } + + public static string? ComposeClientLocal( + InventoryContainerPlacementRejection rejection, + ClientObject? item, + ClientObject? destination, + uint playerId) + { + string itemName = item?.GetAppropriateName() ?? "item"; + string destinationName = destination?.GetAppropriateName() ?? "container"; + return rejection switch + { + InventoryContainerPlacementRejection.None => null, + InventoryContainerPlacementRejection.InvalidItem => "That item is not valid!", + InventoryContainerPlacementRejection.CannotMovePlayer => + "You cannot place yourself within another object!", + InventoryContainerPlacementRejection.CannotMoveCreature => + "You cannot pick up creatures!", + InventoryContainerPlacementRejection.SourceBeingTraded => + $"The {itemName} is being traded", + InventoryContainerPlacementRejection.InvalidDestination => + "The destination container is not valid!", + InventoryContainerPlacementRejection.DestinationBeingTraded => + $"The {destinationName} is being traded", + InventoryContainerPlacementRejection.RecursiveContainment => + "You cannot place an object within itself!", + InventoryContainerPlacementRejection.ItemCapacityFull => + destination?.ObjectId == playerId + ? $"{destinationName} is completely full!" + : $"The {destinationName} is completely full!", + InventoryContainerPlacementRejection.ContainerCapacityFull => + destination?.ObjectId == playerId + ? $"{destinationName} can carry no more containers!" + : $"The {destinationName} can fit no more containers!", + _ => null, + }; + } + + public static bool IsContainer(ClientObject item) + => item.ContainerTypeHint != 0u + || (item.Type & ItemType.Container) != 0 + || item.ItemsCapacity != 0 + || item.ContainersCapacity != 0; + + private static int CountItems(ClientObjectTable objects, uint containerId) + { + int count = 0; + foreach (uint childId in objects.GetContents(containerId)) + { + if (objects.Get(childId) is { } child && !IsContainer(child)) + count++; + } + return count; + } + + private static int CountContainers(ClientObjectTable objects, uint containerId) + { + int count = 0; + foreach (uint childId in objects.GetContents(containerId)) + { + if (objects.Get(childId) is { } child && IsContainer(child)) + count++; + } + return count; + } + + private static bool IsContainedBy( + ClientObjectTable objects, + uint candidateId, + uint possibleAncestorId) + { + var visited = new HashSet(); + uint current = candidateId; + while (current != 0u && visited.Add(current)) + { + if (current == possibleAncestorId) + return true; + current = objects.Get(current)?.ContainerId ?? 0u; + } + return false; + } +} diff --git a/src/AcDream.Core/Items/InventoryTransactionState.cs b/src/AcDream.Core/Items/InventoryTransactionState.cs index bf6ab57d..8e4c75c0 100644 --- a/src/AcDream.Core/Items/InventoryTransactionState.cs +++ b/src/AcDream.Core/Items/InventoryTransactionState.cs @@ -6,8 +6,10 @@ public enum InventoryRequestKind PutInContainer, SplitToContainer, Merge, + Move, DropToWorld, SplitToWorld, + Wield, Give, } diff --git a/src/AcDream.Core/Items/ItemInteractionPolicy.cs b/src/AcDream.Core/Items/ItemInteractionPolicy.cs index 2eb3d4bc..0e4dbc29 100644 --- a/src/AcDream.Core/Items/ItemInteractionPolicy.cs +++ b/src/AcDream.Core/Items/ItemInteractionPolicy.cs @@ -20,13 +20,22 @@ public enum PublicWeenieFlags : uint Attackable = 0x00000010, /// PWD bit 5 — ACCWeenieObject::IsPK @0x0058C8B0. PlayerKiller = 0x00000020, + HiddenAdmin = 0x00000040, + UiHidden = 0x00000080, + Book = 0x00000100, Vendor = 0x00000200, PlayerKillerSwitch = 0x00000400, NonPlayerKillerSwitch = 0x00000800, Door = 0x00001000, Corpse = 0x00002000, + Lifestone = 0x00004000, + Food = 0x00008000, Healer = 0x00010000, Lockpick = 0x00020000, + Portal = 0x00040000, + Admin = 0x00100000, + FreePlayerKiller = 0x00200000, + ImmuneCellRestrictions = 0x00400000, RequiresPackSlot = 0x00800000, /// /// F4 (Slice 6b/6c review): BF_RETAINED, the "unsellable" bit @@ -37,6 +46,8 @@ public enum PublicWeenieFlags : uint Retained = 0x01000000, /// PWD bit 0x19 (25) — ACCWeenieObject::IsPKLite @0x0058C8A0. PlayerKillerLite = 0x02000000, + IncludesSecondHeader = 0x04000000, + Bindstone = 0x08000000, VolatileRare = 0x10000000, WieldOnUse = 0x20000000, WieldLeft = 0x40000000, @@ -79,7 +90,8 @@ public readonly record struct ItemPolicyObject( int TradeState, int StackSize, int MaxSplitSize, - bool IsIn3DView) + bool IsIn3DView, + string Name = "item") { public bool IsPlayer => (Flags & PublicWeenieFlags.Player) != 0; } @@ -249,11 +261,11 @@ public static class ItemInteractionPolicy } if (source.TradeState == 1) - return Reject("You cannot use an item while it is being traded."); + return Reject($"You cannot use the {NameOf(source)} because you are trading it"); if (source.CurrentLocation == EquipMask.None && ItemUseability.LeastLimitedSourceUse(source.Useability) == ItemUseability.Wielded) - return Reject("You must wield that item before you can use it."); + return Reject($"You must wield the {NameOf(source)} to use it"); if (ItemUseability.IsTargeted(source.Useability)) { @@ -261,9 +273,9 @@ public static class ItemInteractionPolicy return Consumed(new ItemPolicyAction(ItemPolicyActionKind.EnterTargetMode, source.Id)); if (input.SelectedTarget is not { } target) - return Reject("Select a target for this item first."); - if (!IsTargetCompatible(source, target, input.PlayerId)) - return Reject("That is not a valid target for this item."); + return Reject($"Select your target before using the {NameOf(source)}"); + if (TargetCompatibilityFailure(source, target, input.PlayerId) is { } failure) + return Reject(failure); var actions = new List { @@ -305,13 +317,13 @@ public static class ItemInteractionPolicy if (source.Id == input.PlayerId) return new ItemUsePolicyDecision(false, Array.Empty()); if ((source.Flags & PublicWeenieFlags.Door) != 0) - return Reject("You cannot open or close that object right now."); + return Reject($"You can't open or close this {NameOf(source)} that way"); if ((source.Flags & PublicWeenieFlags.Attackable) != 0 && input.InNonCombatMode) - return Reject("You must switch to a combat mode before attacking that target."); + return Reject($"To attack {NameOf(source)}, click on the dove icon first"); if ((source.Flags & PublicWeenieFlags.Attackable) == 0 || input.InNonCombatMode) - return Reject("That object cannot be used."); + return Reject($"The {NameOf(source)} cannot be used"); return new ItemUsePolicyDecision(false, Array.Empty()); } @@ -319,9 +331,18 @@ public static class ItemInteractionPolicy in ItemPolicyObject source, in ItemPolicyObject target, uint playerId) + => TargetCompatibilityFailure(source, target, playerId) is null; + + private static string? TargetCompatibilityFailure( + in ItemPolicyObject source, + in ItemPolicyObject target, + uint playerId) { if (source.TradeState == 1) - return false; + return $"You cannot use the {NameOf(source)} because you are trading it"; + + if (target.TradeState == 1) + return $"You can't use the {NameOf(source)} on an item you are trading"; uint flags = ItemUseability.TargetFlags(source.Useability); if (!target.OwnedByPlayer) @@ -330,18 +351,20 @@ public static class ItemInteractionPolicy if ((least & ItemUseability.Contained) != 0) { if (!(target.Id == playerId && (flags & ItemUseability.Self) != 0)) - return false; + return $"You can't use the {NameOf(source)} on what you don't own"; } else if ((least & ItemUseability.Wielded) != 0) { - return false; + return $"You can't use the {NameOf(source)} on what you aren't wielding"; } } if (target.Id == playerId && (flags & ItemUseability.Self) == 0) - return false; + return $"Cannot use the {NameOf(source)} on yourself"; - return (source.TargetType & (uint)target.Type) != 0; + return (source.TargetType & (uint)target.Type) != 0 + ? null + : $"Cannot use the {NameOf(source)} with the {NameOf(target)}"; } public static ItemPlacementPolicyDecision DecidePlacement( @@ -353,9 +376,10 @@ public static class ItemInteractionPolicy if (input.TargetId == input.PlayerId) return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.PlaceInBackpack, input.Item.Id)); if (!input.Item.OwnedByPlayer) - return Placement(false, RejectAction("You must first pick up that item.")); + return Placement(false, RejectAction($"You must first pick up the {NameOf(input.Item)}")); if (input.Item.TradeState != 0) - return Placement(false, RejectAction("You cannot move an item while it is being traded.")); + return Placement(false, RejectAction( + $"You are trading the {NameOf(input.Item)}, it cannot be dropped")); if (input.TargetId == 0) return input.AllowGroundFallback ? PlaceOnGround(input) : Placement(false); @@ -370,7 +394,7 @@ public static class ItemInteractionPolicy if (input.SplitSize >= input.Item.MaxSplitSize) return Placement(false, new ItemPolicyAction(ItemPolicyActionKind.SellToVendor, input.Item.Id, target.Id, input.SplitSize)); - return Placement(false, RejectAction("Split the stack before selling part of it.")); + return Placement(false, RejectAction("You must split the stack before selling it.")); } if (input.DragOnPlayerOpensSecureTrade && target.IsPlayer) @@ -384,16 +408,17 @@ public static class ItemInteractionPolicy if (target.IsContainer) { if ((target.Flags & PublicWeenieFlags.Openable) == 0) - return Placement(false, RejectAction("That container is locked.")); + return Placement(false, RejectAction($"The {NameOf(target)} is locked")); if (target.Id != input.GroundObjectId) - return Placement(false, RejectAction("You must open that container first.")); + return Placement(false, RejectAction($"You must open the {NameOf(target)} first")); return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.PlaceInContainer, input.Item.Id, target.Id, input.SplitSize)); } if (input.AllowGroundFallback) return PlaceOnGround(input); - return Placement(false, RejectAction("You cannot give that item to this target.")); + return Placement(false, RejectAction( + $"Cannot give {NameOf(input.Item)} to {NameOf(target)}")); } private static IReadOnlyList BuildUsingItemActions( @@ -436,15 +461,18 @@ public static class ItemInteractionPolicy in ItemPlacementPolicyInput input) { if (!input.PlayerOnGround) - return Placement(false, RejectAction("You cannot do that in mid air.")); + return Placement(false, RejectAction("You cannot do that in mid air")); if (input.SplitSize < input.Item.MaxSplitSize) return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.SplitToWorld, input.Item.Id, Amount: input.SplitSize)); if (!input.Item.IsIn3DView) return Placement(true, new ItemPolicyAction(ItemPolicyActionKind.DropToWorld, input.Item.Id)); - return Placement(false, RejectAction("Move cancelled.")); + return Placement(false, RejectAction("Move cancelled")); } + private static string NameOf(in ItemPolicyObject item) + => string.IsNullOrWhiteSpace(item.Name) ? "item" : item.Name; + private static ItemUsePolicyDecision Consumed(params ItemPolicyAction[] actions) => new(true, actions); diff --git a/src/AcDream.Core/Items/VendorStagingList.cs b/src/AcDream.Core/Items/VendorStagingList.cs index d89f102f..1d1af85f 100644 --- a/src/AcDream.Core/Items/VendorStagingList.cs +++ b/src/AcDream.Core/Items/VendorStagingList.cs @@ -127,6 +127,27 @@ public sealed class VendorStagingList return true; } + /// + /// Replaces retail's temporary pre-split sell-row identity with the + /// server-created split stack while preserving the row's position and + /// selected quantity. VendorSellUI::ItemAttributesChanged + /// performs the same in-place substitution after matching the new + /// object's class id and stack size. + /// + public bool Replace(uint itemGuid, uint replacementGuid) + { + if (itemGuid == 0u || replacementGuid == 0u || itemGuid == replacementGuid) + return false; + + int index = _entries.FindIndex(entry => entry.ItemGuid == itemGuid); + if (index < 0 || _entries.Exists(entry => entry.ItemGuid == replacementGuid)) + return false; + + _entries[index] = _entries[index] with { ItemGuid = replacementGuid }; + Changed?.Invoke(); + return true; + } + /// Port of the unconditional PackableList<ItemProfile>::Flush calls /// ("Clear List" buttons, and the optimistic post-send clear both Buy All and Sell All /// perform immediately after their wire send — see the batched-send call sites). diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs index 5aed4aec..df221885 100644 --- a/src/AcDream.Core/Physics/MotionInterpreter.cs +++ b/src/AcDream.Core/Physics/MotionInterpreter.cs @@ -2269,8 +2269,9 @@ public sealed class MotionInterpreter : IMotionDoneSink if (PhysicsObj is null) return false; - bool grounded = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact) - && PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable); + const TransientStateFlags groundedMask = + TransientStateFlags.Contact | TransientStateFlags.OnWalkable; + bool grounded = (PhysicsObj.TransientState & groundedMask) == groundedMask; if (!grounded) return false; diff --git a/src/AcDream.Core/Physics/RawMotionState.cs b/src/AcDream.Core/Physics/RawMotionState.cs index 315ee1ff..fa12e9aa 100644 --- a/src/AcDream.Core/Physics/RawMotionState.cs +++ b/src/AcDream.Core/Physics/RawMotionState.cs @@ -74,6 +74,32 @@ public readonly record struct RawMotionAction( /// public sealed class RawMotionState { + public RawMotionState() + { + } + + /// + /// Deep snapshot used at retail's synchronous SendMovementEvent boundary. + /// The action FIFO is copied so animation completion cannot mutate a + /// packet that has already been requested. + /// + public RawMotionState(RawMotionState other) + { + ArgumentNullException.ThrowIfNull(other); + CurrentHoldKey = other.CurrentHoldKey; + CurrentStyle = other.CurrentStyle; + ForwardCommand = other.ForwardCommand; + ForwardHoldKey = other.ForwardHoldKey; + ForwardSpeed = other.ForwardSpeed; + SidestepCommand = other.SidestepCommand; + SidestepHoldKey = other.SidestepHoldKey; + SidestepSpeed = other.SidestepSpeed; + TurnCommand = other.TurnCommand; + TurnHoldKey = other.TurnHoldKey; + TurnSpeed = other.TurnSpeed; + _actions.AddRange(other._actions); + } + /// Retail current_holdkey (ctor default HoldKey_None). public HoldKey CurrentHoldKey { get; set; } = HoldKey.None; /// Retail current_style (ctor default 0x8000003D, NonCombat). diff --git a/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs b/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs index b4128b39..9296a7c0 100644 --- a/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs +++ b/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs @@ -21,7 +21,10 @@ public sealed record LiveChatCommandBindings( Action SendTell, Action SendChannel, Action SendTurbineChat, - Action? Log = null); + Action? Log = null, + Func? ResolvePose = null, + Action? ExecuteMotion = null, + Action? SendSoulEmote = null); /// /// One generation's active binding for the four chat-core records. The route @@ -153,7 +156,7 @@ public sealed class LiveChatCommandRoute switch (command.Channel) { case ChatChannelKind.Say: - SendIfActive(() => bindings.SendTalk(command.Text)); + RoutePublicChat(bindings, command.Text); return; case ChatChannelKind.Tell: @@ -191,6 +194,25 @@ public sealed class LiveChatCommandRoute RouteLegacyChannel(bindings, command.Channel, command.Text); } + private void RoutePublicChat( + LiveChatCommandBindings bindings, + string text) + { + string spoken = RetailPublicChatParser.ExtractPoses( + text, + bindings.ResolvePose, + pose => + { + bindings.ExecuteMotion?.Invoke(pose.MotionCommand); + if (!string.IsNullOrEmpty(pose.OthersText)) + bindings.SendSoulEmote?.Invoke(pose.OthersText); + if (!string.IsNullOrEmpty(pose.SelfText)) + bindings.Chat.OnSoulEmote("You", pose.SelfText, 0u); + }); + if (!string.IsNullOrEmpty(spoken)) + SendIfActive(() => bindings.SendTalk(spoken)); + } + private void RouteTurbineChat( LiveChatCommandBindings bindings, ChatChannelKindLite kind, diff --git a/src/AcDream.Runtime/Chat/RetailPublicChatParser.cs b/src/AcDream.Runtime/Chat/RetailPublicChatParser.cs new file mode 100644 index 00000000..839a6a65 --- /dev/null +++ b/src/AcDream.Runtime/Chat/RetailPublicChatParser.cs @@ -0,0 +1,76 @@ +namespace AcDream.Runtime.Chat; + +/// One DAT-backed ChatPoseTable resolution. +public readonly record struct RetailChatPose( + uint MotionCommand, + string SelfText, + string OthersText); + +/// +/// Ports ClientCommunicationSystem::PublicChat @ 0x005810F0 and +/// RemoveTextBetween @ 0x00580FD0. Valid pose tokens are consumed; +/// unknown or unmatched delimiters remain ordinary speech. +/// +public static class RetailPublicChatParser +{ + public static string ExtractPoses( + string text, + Func? resolve, + Action? execute) + { + ArgumentNullException.ThrowIfNull(text); + if (resolve is null || execute is null || text.Length == 0) + return text.Trim(); + + string remaining = text; + int cursor = 0; + while (cursor < remaining.Length) + { + int star = remaining.IndexOf('*', cursor); + int angle = remaining.IndexOf('<', cursor); + int open; + char close; + if (star < 0) + { + open = angle; + close = '>'; + } + else if (angle < 0 || star <= angle) + { + open = star; + close = '*'; + } + else + { + open = angle; + close = '>'; + } + + if (open < 0) + break; + int end = remaining.IndexOf(close, open + 1); + if (end < 0) + { + cursor = open + 1; + continue; + } + + string command = remaining[(open + 1)..end]; + RetailChatPose? pose = resolve(command); + if (pose is { MotionCommand: not 0u } resolved) + { + execute(resolved); + remaining = remaining.Remove(open, end - open + 1); + cursor = open; + } + else + { + // Pose() returned false: retail leaves the complete literal + // token in the talk text and advances past this pair. + cursor = end + 1; + } + } + + return remaining.Trim(); + } +} diff --git a/src/AcDream.Runtime/GameRuntimeActionViews.cs b/src/AcDream.Runtime/GameRuntimeActionViews.cs index f648986c..c8726b86 100644 --- a/src/AcDream.Runtime/GameRuntimeActionViews.cs +++ b/src/AcDream.Runtime/GameRuntimeActionViews.cs @@ -10,7 +10,8 @@ public readonly record struct RuntimeCombatAttackSnapshot( float PowerBarLevel, bool BuildInProgress, bool RequestInProgress, - float RequestedPower); + float RequestedPower, + bool RepeatAttackInProgress = false); public readonly record struct RuntimeSpellCastSnapshot( long Revision, diff --git a/src/AcDream.Runtime/GameRuntimeCommands.cs b/src/AcDream.Runtime/GameRuntimeCommands.cs index d593910f..0ce06b23 100644 --- a/src/AcDream.Runtime/GameRuntimeCommands.cs +++ b/src/AcDream.Runtime/GameRuntimeCommands.cs @@ -67,6 +67,8 @@ public enum RuntimeMovementCommand Sit, Crouch, Sleep, + StopCompletely, + FinishJump, } public enum RuntimeChatChannel @@ -160,6 +162,10 @@ public interface IRuntimeMovementCommands RuntimeGenerationToken expectedGeneration, RuntimeMovementCommand command); + RuntimeCommandResult ExecuteMotion( + RuntimeGenerationToken expectedGeneration, + uint motionCommand); + RuntimeCommandResult SetIntent( RuntimeGenerationToken expectedGeneration, in Gameplay.MovementInput input); diff --git a/src/AcDream.Runtime/GameRuntimeViews.cs b/src/AcDream.Runtime/GameRuntimeViews.cs index 3c9baf30..f3570372 100644 --- a/src/AcDream.Runtime/GameRuntimeViews.cs +++ b/src/AcDream.Runtime/GameRuntimeViews.cs @@ -86,6 +86,10 @@ public readonly record struct RuntimeMovementSnapshot( public interface IRuntimeMovementView { RuntimeMovementSnapshot Snapshot { get; } + + bool IsStandingStill { get; } + + Gameplay.JumpChargeSnapshot JumpCharge { get; } } public enum RuntimePortalKind diff --git a/src/AcDream.Runtime/Gameplay/LocalPlayerOutboundController.cs b/src/AcDream.Runtime/Gameplay/LocalPlayerOutboundController.cs index 0053e737..4e3d2fa9 100644 --- a/src/AcDream.Runtime/Gameplay/LocalPlayerOutboundController.cs +++ b/src/AcDream.Runtime/Gameplay/LocalPlayerOutboundController.cs @@ -231,6 +231,9 @@ public sealed class LocalPlayerOutboundController public static RawMotionState BuildRawMotionState(MovementResult movement) { + if (movement.RawMotionStateOverride is { } rawMotionState) + return new RawMotionState(rawMotionState); + HoldKey axisHoldKey = movement.IsRunning ? HoldKey.Run : HoldKey.None; return new RawMotionState { diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index 66ed3c81..487d70db 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -110,7 +110,11 @@ public readonly record struct MovementResult( // MovementManager's complete RawMotionState into MoveToStatePack. An // absent style bit unpacks as NonCombat, so the canonical raw style must // travel with every input-boundary snapshot sent to ACE. - uint CurrentStyle = 0x8000003Du); + uint CurrentStyle = 0x8000003Du, + // Retail SendMovementEvent snapshots the COMPLETE RawMotionState + // synchronously. Command-originated motions use this one-shot override so + // the action FIFO/state survives the render-tick input projection. + RawMotionState? RawMotionStateOverride = null); /// /// Portal-space state for the player movement controller. @@ -530,6 +534,25 @@ public sealed class PlayerMovementController /// public JumpChargeSnapshot JumpCharge => new(_jumpCharging, _jumpCharging ? _jumpExtent : 0f); + + /// + /// Retail CommandInterpreter::IsStandingStill: the exact motion- + /// interpreter predicate consumed by Escape before it reaches selection + /// or the Gameplay Options fallback. + /// + internal bool IsStandingStill => _motion.IsStandingStill(); + + /// + /// Retail ClientCombatSystem::FinishJump (0x0056A9B0): end an + /// in-progress jump power build without executing the jump and clear the + /// standing-long-jump arm on the motion interpreter. + /// + internal void FinishJump() + { + _jumpCharging = false; + _jumpExtent = 0f; + _motion.StandingLongJump = false; + } // Matching v11.4186 x86 resolves GetPowerBarLevel's collapsed x87 // operands: ATTACK_POWERUP_TIME=1.0 s, DUAL_WIELD_POWERUP_TIME=0.8 s. // Jump uses the same shared powerbar function, so its normal fill rate is @@ -656,6 +679,8 @@ public sealed class PlayerMovementController private readonly AcDream.Core.Physics.Motion.MotionDeltaFrame _positionManagerDeltaScratch = new(); private bool _externalMovementEventPending; + private RawMotionState? _externalRawMotionStatePending; + private uint _localActionStamp; // ── R4-V5: the verbatim retail MoveToManager replaces B.6 auto-walk ── // The B.6 DriveServerAutoWalk overlay (synthesized turn-first phase, @@ -1441,6 +1466,34 @@ public sealed class PlayerMovementController return true; } + /// + /// Retail ACCmdInterp::SetMotion (0x0058B310) with + /// start=true: submit one raw command through the local physics-object + /// boundary and publish the resulting movement edge on the next turn. + /// + internal bool RequestCommandMotion(uint motion) + { + EnsurePublishedForRuntimeOperation(); + TakeControlFromServer(); + var parameters = + new AcDream.Core.Physics.Motion.MovementParameters + { + Autonomous = true, + ActionStamp = _localActionStamp, + }; + if (DoMotionAtPhysicsObjectBoundary(motion, parameters) + != WeenieError.None) + { + return false; + } + + if ((motion & 0x10000000u) != 0u) + _localActionStamp++; + _externalRawMotionStatePending = new RawMotionState(_motion.RawState); + _externalMovementEventPending = true; + return true; + } + public void SetCharacterSkills(int runSkill, int jumpSkill) { EnsureConfigurationMutable(); @@ -2452,6 +2505,9 @@ public sealed class PlayerMovementController bool externallyRequestedMovementEvent = _externalMovementEventPending; _externalMovementEventPending = false; + RawMotionState? externalRawMotionState = + _externalRawMotionStatePending; + _externalRawMotionStatePending = null; bool motionEdgeFired = false; bool movementEventRequested = externallyRequestedMovementEvent; @@ -3189,7 +3245,8 @@ public sealed class PlayerMovementController SidestepUsesRunHold: _activeInputSidestepUsesRunHold && outSidestepCmd.HasValue, IsMouseLookMovementEvent: mouseMovementEventDue, - CurrentStyle: _motion.RawState.CurrentStyle); + CurrentStyle: _motion.RawState.CurrentStyle, + RawMotionStateOverride: externalRawMotionState); } /// diff --git a/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs b/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs index 61ca4bb3..fcd76db7 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs @@ -253,7 +253,8 @@ public sealed class RuntimeActionState : IDisposable owner.CombatAttack.PowerBarLevel, owner.CombatAttack.BuildInProgress, owner.CombatAttack.AttackRequestInProgress, - owner.CombatAttack.RequestedAttackPower), + owner.CombatAttack.RequestedAttackPower, + owner.CombatAttack.RepeatAttackInProgress), new RuntimeSpellCastSnapshot( Interlocked.Read(ref owner._magicIntentRevision), owner.SpellCast.LastRequestedSpellId ?? 0u, diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs index 20bedded..6c0eccb7 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs @@ -152,6 +152,7 @@ public sealed class RuntimeCombatAttackState : IDisposable public AttackHeight RequestedHeight { get; private set; } = AttackHeight.Medium; public float DesiredPower { get; private set; } = InitialDesiredPower; public bool AttackRequestInProgress => _attackRequestInProgress; + public bool RepeatAttackInProgress => _repeatAttacking; public float RequestedAttackPower => _requestedAttackPower; public bool BuildInProgress => _buildInProgress; public bool IsDisposed => _disposed; diff --git a/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs b/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs index 9769b844..73f85a60 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeInventoryState.cs @@ -16,6 +16,7 @@ public readonly record struct RuntimeInventoryOwnershipSnapshot( int ShortcutSubscriberCount, long ShortcutDispatchFailureCount, long TransactionDispatchFailureCount, + int OpenedCorpseCount, // Slice 5.3: the sole open vendor shop id, 0 when no session is open. uint VendorId, // Slice 6.1: guids VendorShopItemMaterializer currently owns in @@ -36,6 +37,7 @@ public readonly record struct RuntimeInventoryOwnershipSnapshot( && ItemManaCount == 0 && ShortcutCount == 0 && ShortcutSubscriberCount == 0 + && OpenedCorpseCount == 0 && VendorId == 0u && MaterializedVendorItemCount == 0; } @@ -55,6 +57,7 @@ public sealed class RuntimeInventoryState : IDisposable _entityObjects = entityObjects ?? throw new ArgumentNullException(nameof(entityObjects)); ExternalContainers = new ExternalContainerState(); + _entityObjects.Objects.ObjectRemoved += OnObjectRemoved; ItemMana = new ItemManaState(); Shortcuts = new ShortcutStore(); Transactions = new InventoryTransactionState(_entityObjects.Objects); @@ -98,6 +101,7 @@ public sealed class RuntimeInventoryState : IDisposable Shortcuts.SubscriberCount, Shortcuts.DispatchFailureCount, Transactions.DispatchFailureCount, + ExternalContainers.OpenedCorpseCount, Vendor.VendorId, VendorItems.OwnedCount); @@ -170,6 +174,7 @@ public sealed class RuntimeInventoryState : IDisposable List? failures = null; try { + _entityObjects.Objects.ObjectRemoved -= OnObjectRemoved; Try(() => ExternalContainers.Reset(), ref failures); // Vendor.Reset() must run BEFORE VendorItems.Dispose() — // Reset() fires Changed synchronously, which is what drives the @@ -208,6 +213,9 @@ public sealed class RuntimeInventoryState : IDisposable } } + private void OnObjectRemoved(ClientObject item) + => ExternalContainers.SetCorpseDeleted(item.ObjectId); + private sealed class InventoryStateView(RuntimeInventoryState owner) : IRuntimeInventoryStateView { diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs index 991204e2..d354b974 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs @@ -172,6 +172,8 @@ public sealed class RuntimeLocalPlayerMovementState public long Revision => Interlocked.Read(ref _revision); public ulong ControllerOwnershipEpoch { get; private set; } public IRuntimeMovementView View => this; + public bool IsStandingStill => _controller?.IsStandingStill ?? true; + public JumpChargeSnapshot JumpCharge => _controller?.JumpCharge ?? default; internal RuntimeLocalPlayerPhysicsPublicationState PhysicsPublication => _physicsPublication ?? throw new InvalidOperationException( @@ -246,6 +248,16 @@ public sealed class RuntimeLocalPlayerMovementState CancelAutoRun(); ClearCommandInput(); return true; + case RuntimeMovementCommand.StopCompletely: + CancelAutoRun(); + ClearCommandInput(); + _ = _controller?.StopCompletelyAtPhysicsObjectBoundary(); + Interlocked.Increment(ref _revision); + return true; + case RuntimeMovementCommand.FinishJump: + _controller?.FinishJump(); + Interlocked.Increment(ref _revision); + return true; case RuntimeMovementCommand.Ready: case RuntimeMovementCommand.Sit: case RuntimeMovementCommand.Crouch: @@ -270,6 +282,17 @@ public sealed class RuntimeLocalPlayerMovementState } } + /// + /// Executes a retail command-interpreter motion on the canonical local + /// player. Keyboard emotes use this exact route; the caller owns the + /// ActionMap-to-motion allowlist. + /// + public bool ExecuteMotion(uint motionCommand) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return _controller?.RequestCommandMotion(motionCommand) == true; + } + public bool CancelAutoRun() { ObjectDisposedException.ThrowIf(_disposed, this); diff --git a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs index e70ece3c..9c596a29 100644 --- a/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs +++ b/src/AcDream.Runtime/Session/DirectGameRuntimeCommandAdapter.cs @@ -403,6 +403,25 @@ public sealed class DirectGameRuntimeCommandAdapter status); } + public RuntimeCommandResult ExecuteMotion( + RuntimeGenerationToken expectedGeneration, + uint motionCommand) + { + RuntimeCommandStatus gate = + Validate(expectedGeneration, out _); + if (gate != RuntimeCommandStatus.Accepted) + return Result(gate); + RuntimeCommandStatus status = + _runtime.MovementOwner.ExecuteMotion(motionCommand) + ? RuntimeCommandStatus.Accepted + : RuntimeCommandStatus.Unsupported; + return EmitResult( + RuntimeCommandDomain.Movement, + operation: 0x102, + status, + motionCommand); + } + public RuntimeCommandResult SetIntent( RuntimeGenerationToken expectedGeneration, in MovementInput input) diff --git a/src/AcDream.UI.Abstractions/Input/InputAction.cs b/src/AcDream.UI.Abstractions/Input/InputAction.cs index be2228f8..32913a0b 100644 --- a/src/AcDream.UI.Abstractions/Input/InputAction.cs +++ b/src/AcDream.UI.Abstractions/Input/InputAction.cs @@ -8,10 +8,9 @@ namespace AcDream.UI.Abstractions.Input; /// debug bindings that have no retail equivalent. /// /// -/// K.1a defined the enum and K.1c flipped the bindings table to the full -/// retail preset. Runtime controllers subscribe by subsystem; actions whose -/// owning panel has not landed yet (for example UseSpellSlot_*) may -/// intentionally remain undispatched. +/// The installed Sept-2013 ActionMap's 306 user-bindable rows each have one +/// distinct enum identity and one live subsystem consumer. Low, non-bindable +/// MasterInputMap commands remain separate infrastructure actions. /// /// public enum InputAction @@ -92,7 +91,10 @@ public enum InputAction // ── UICommands ──────────────────────────────────────── /// Use the selected item / interact (retail R). UseSelected, - /// Cancel the topmost UI / clear selection / open log-out menu. + /// + /// Retail Escape priority: cancel focused UI/targeting/movement, clear + /// selection, then toggle the Gameplay Options page. + /// EscapeKey, /// Log out of the game (retail Shift+Esc). LOGOUT, @@ -169,7 +171,7 @@ public enum InputAction // ── Combat ──────────────────────────────────────────── /// Toggle combat-stance on/off (retail Grave / `). CombatToggleCombat, - // Mode-dependent (dormant in K — Phase L lights them up) + // Mode-dependent retail combat actions. CombatDecreaseAttackPower, CombatIncreaseAttackPower, CombatLowAttack, @@ -267,7 +269,8 @@ public enum InputAction AcdreamToggleAudioMute, /// F (existing) toggles between fly camera and orbit/chase mode. AcdreamToggleFlyMode, - /// Tab — currently toggles fly↔player mode (will be reassigned to ToggleChatEntry in K.1c). + /// Legacy acdream player-mode toggle. Intentionally unbound; + /// retail Tab is . AcdreamTogglePlayerMode, /// Hold-RMB chase-camera orbit (debug-only, not user-rebindable). /// Camera orbits around the player while held; never drives character yaw. @@ -285,4 +288,201 @@ public enum InputAction CameraRaise, /// Camera lower (held key, integrates Pitch−= adjSpeed·dt·0.02). Default unbound. CameraLower, + + // ── Remaining Sept-2013 retail ActionMap identities ──────────── + // Appended after every pre-existing member so persisted numeric enum values + // remain stable. These are distinct even where retail reuses the same + // Action id in another InputMap (notably CameraAlternateControls). + + CameraAlternateMoveToward, + CameraAlternateMoveAway, + CameraAlternateRotateLeft, + CameraAlternateRotateRight, + CameraAlternateRotateUp, + CameraAlternateRotateDown, + CameraAlternateViewDefault, + CameraAlternateViewFirstPerson, + CameraAlternateViewLookDown, + CameraAlternateViewMapMode, + + UseSpellSlot_10, + UseSpellSlot_11, + UseSpellSlot_12, + + EmoteAfkState, + EmoteAkimbo, + EmoteAToyotState, + EmoteAkimboState, + EmoteAtEaseState, + EmoteBeckon, + EmoteBeSeeingYou, + EmoteBlowKiss, + EmoteBowDeep, + EmoteBowDeepState, + EmoteClapHands, + EmoteClapHandsState, + EmoteCringe, + EmoteCrossArmsState, + EmoteCurtseyState, + EmoteDrudgeDance, + EmoteDrudgeDanceState, + EmoteHaveASeat, + EmoteHaveASeatState, + EmoteHeartyLaugh, + EmoteHelper, + EmoteKneel, + EmoteKneelState, + EmoteKnock, + EmoteLeanState, + EmoteMeditateState, + EmoteMimeDrinking, + EmoteMimeEating, + EmoteMock, + EmoteNod, + EmoteNudgeLeft, + EmoteNudgeRight, + EmotePlead, + EmotePleadState, + EmotePoint, + EmotePointDown, + EmotePointDownState, + EmotePointLeft, + EmotePointLeftState, + EmotePointRight, + EmotePointRightState, + EmotePossumState, + EmotePray, + EmotePrayState, + EmoteReadState, + EmoteSalute, + EmoteSaluteState, + EmoteScanHorizon, + EmoteScratchHead, + EmoteScratchHeadState, + EmoteShakeFist, + EmoteShakeFistState, + EmoteShakeHead, + EmoteShiver, + EmoteShiverState, + EmoteShoo, + EmoteShrug, + EmoteSitState, + EmoteSitBackState, + EmoteSitCrossleggedState, + EmoteSlouch, + EmoteSlouchState, + EmoteSmackHead, + EmoteSnowAngelState, + EmoteSpit, + EmoteSurrender, + EmoteSurrenderState, + EmoteTalkToTheHandState, + EmoteTapFoot, + EmoteTapFootState, + EmoteTeapot, + EmoteThinkerState, + EmoteWarmHands, + EmoteWaveState, + EmoteWaveLow, + EmoteWaveHigh, + EmoteWinded, + EmoteWindedState, + EmoteWoah, + EmoteWoahState, + EmoteYawnAndStretch, + EmoteYmca, + + SelectionSelf, + SelectionPlaceInInventory, + SelectionUseClosestUnopenedCorpse, + SelectionUseNextUnopenedCorpse, + SelectionGiveToTarget, + SelectionDrop, + SelectionPlaceInMainPack, + SelectionClosestUnopenedCorpse, + SelectionNextUnopenedCorpse, + + ToggleAbuseReportingPanel, + ToggleCharacterInfoPanel, + TogglePositiveMagicPanel, + ToggleNegativeMagicPanel, + ToggleLinkStatusPanel, + ToggleUrgentAssistancePanel, + ToggleVitaePanel, + ToggleSocialPanel, + ToggleSpellManagementPanel, + ToggleCharacterDetailPanel, + ToggleMapPage, + ToggleHousePage, + ToggleGameplayOptionsPage, + ToggleCharacterSettingsPage, + ToggleConfigurationPage, + ToggleCompass, + ToggleKeyboardConfiguration, + ToggleFriendsPage, + ToggleCharacterTitlesPage, + ToggleQuestDetailPanel, + ToggleQuestJournalPage, + ToggleJournalPageList, + ToggleContractsPage, + + ChatMonarchReply, + ChatPatronReply, + ChatReply, + ChatStartCommand, + ChatTellToSelected, + + UseQuickSlot_10, + UseQuickSlot_11, + UseQuickSlot_12, + UseQuickSlot_13, + + ToggleCharacterOptionAutoRepeatAttack, + ToggleCharacterOptionIgnoreAllegianceRequests, + ToggleCharacterOptionIgnoreFellowshipRequests, + ToggleCharacterOptionIgnoreTradeRequests, + ToggleCharacterOptionPersistentAtDay, + ToggleCharacterOptionAllowGive, + ToggleCharacterOptionViewCombatTarget, + ToggleCharacterOptionShowTooltips, + ToggleCharacterOptionUseDeception, + ToggleCharacterOptionToggleRun, + ToggleCharacterOptionStayInChatMode, + ToggleCharacterOptionAdvancedCombatUi, + ToggleCharacterOptionAutoTarget, + ToggleCharacterOptionVividTargetingIndicator, + ToggleCharacterOptionFellowshipShareXp, + ToggleCharacterOptionAcceptLootPermits, + ToggleCharacterOptionFellowshipShareLoot, + ToggleCharacterOptionFellowshipAutoAcceptRequests, + ToggleCharacterOptionCoordinatesOnRadar, + ToggleCharacterOptionSpellDuration, + ToggleCharacterOptionDisableHouseRestrictionEffects, + ToggleCharacterOptionDragItemOnPlayerOpensSecureTrade, + ToggleCharacterOptionDisplayAllegianceLogonNotifications, + ToggleCharacterOptionUseChargeAttack, + ToggleCharacterOptionUseCraftSuccessDialog, + ToggleCharacterOptionListenToAllegianceChat, + ToggleCharacterOptionDisplayDateOfBirth, + ToggleCharacterOptionDisplayAge, + ToggleCharacterOptionDisplayChessRank, + ToggleCharacterOptionDisplayFishingSkill, + ToggleCharacterOptionDisplayNumberDeaths, + ToggleCharacterOptionDisplayTimeStamps, + ToggleCharacterOptionSalvageMultiple, + ToggleCharacterOptionListenToGeneralChat, + ToggleCharacterOptionListenToTradeChat, + ToggleCharacterOptionListenToLfgChat, + ToggleCharacterOptionListenToRoleplayChat, + ToggleCharacterOptionDisplayNumberCharacterTitles, + ToggleCharacterOptionMainPackPreferred, + ToggleCharacterOptionLeadMissileTargets, + ToggleCharacterOptionUseFastMissiles, + ToggleCharacterOptionFilterLanguage, + ToggleCharacterOptionConfirmVolatileRareUse, + ToggleCharacterOptionListenToSocietyChat, + ToggleCharacterOptionShowHelm, + ToggleCharacterOptionDisableDistanceFog, + ToggleCharacterOptionShowCloak, + ToggleCharacterOptionSideBySideVitals, } diff --git a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs index 80cfc680..22b7da11 100644 --- a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs +++ b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs @@ -22,12 +22,9 @@ namespace AcDream.UI.Abstractions.Input; /// /// /// -/// K.1a wiring: GameWindow constructs a dispatcher alongside the -/// existing IsKeyPressed + event-handler paths. Nothing -/// subscribes to yet except a diagnostic console -/// logger — the dispatcher is observable but doesn't drive any -/// behavior. K.1b cuts the existing handlers over to the dispatcher's -/// action stream. +/// The production gameplay router is the sole gameplay subscriber; retained +/// UI, selection, camera, combat, movement, and commands all receive semantic +/// actions through this stream. /// /// public sealed class InputDispatcher : IDisposable @@ -38,6 +35,7 @@ public sealed class InputDispatcher : IDisposable private KeyBindings _bindings; private readonly Stack _scopes = new(); private InputScope? _combatScope; + private bool _cameraAlternateScope; private readonly HashSet _heldHoldChords = new(); private readonly HashSet _automationHeldActions = new(); private readonly Dictionary _mouseClickTravel = new(); @@ -55,16 +53,28 @@ public sealed class InputDispatcher : IDisposable private const long DoubleClickThresholdMs = 500; private const float ClickDragThresholdPixels = 3f; - /// K.3 modal-rebind hook: when non-null, the next non-modifier - /// chord is reported via this callback INSTEAD of firing actions. Esc - /// cancels (callback receives default(KeyChord)). + /// K.3 modal-rebind hook: when non-null, the next complete key or + /// mouse chord is reported via this callback INSTEAD of firing actions. + /// A modifier key is deferred until release so it can be captured alone or + /// used as a prefix. Esc cancels (callback receives + /// default(KeyChord)). private Action? _captureCallback; + private Key? _captureModifierCandidate; + private KeyChord? _currentPhysicalChord; /// Fires every time a binding matches a press, release, hold, /// complete click, or double-click. /// Multicast — every subscriber gets every event in subscription order. public event Action? Fired; + /// + /// The keyboard chord whose native key-down callback is synchronously + /// publishing , or outside that + /// callback. This lets retained UI suppress the raw tail of the same key + /// after a semantic action has just moved keyboard focus. + /// + public KeyChord? CurrentPhysicalChord => _currentPhysicalChord; + private InputDispatcher( IKeyboardSource keyboard, IMouseSource mouse, @@ -158,9 +168,11 @@ public sealed class InputDispatcher : IDisposable { Interlocked.Exchange(ref _active, 0); _captureCallback = null; + _captureModifierCandidate = null; _heldHoldChords.Clear(); _automationHeldActions.Clear(); _mouseClickTravel.Clear(); + _cameraAlternateScope = false; } public void Dispose() @@ -226,9 +238,24 @@ public sealed class InputDispatcher : IDisposable } /// Topmost scope on the stack — what the dispatcher looks up first. - public InputScope ActiveScope => _scopes.Peek() == InputScope.Game && _combatScope is { } combat - ? combat - : _scopes.Peek(); + public InputScope ActiveScope => _cameraAlternateScope + ? InputScope.Camera + : _scopes.Peek() == InputScope.Game && _combatScope is { } combat + ? combat + : _scopes.Peek(); + + /// + /// Installs retail InputMap 6 while the camera-mode chord (F2 or keypad + /// divide by default) is physically held. Retail registers this map at + /// priority 2000 over the ordinary priority-1000 maps, so its arrow-key + /// bindings shadow movement without replacing the normal scope stack. + /// + public void SetCameraAlternateScope(bool active) + { + if (_cameraAlternateScope == active) return; + ReleaseHeldHoldBindings(); + _cameraAlternateScope = active; + } /// Set the mode-dependent combat layer that shadows normal game chords. public void SetCombatScope(InputScope? scope) @@ -243,34 +270,72 @@ public sealed class InputDispatcher : IDisposable private Binding? FindActive(KeyChord chord, ActivationType activation) { + IReadOnlyList bindings = FindActiveBindings(chord, activation); + return bindings.Count == 0 ? null : bindings[0]; + } + + /// + /// Returns every binding in the highest-priority active retail InputMap. + /// Retail's shipped map deliberately assigns Alt+1..4 in both UICommands + /// and QuickslotCommands; ICIDM emits both actions because those maps are + /// simultaneously active. A first-match lookup made half of those exact + /// defaults unreachable. + /// + private IReadOnlyList FindActiveBindings( + KeyChord chord, + ActivationType activation) + { + if (_cameraAlternateScope) + { + Binding[] camera = FindInScope(InputScope.Camera, chord, activation); + if (camera.Length != 0) + return camera; + } + foreach (InputScope scope in _scopes) { - if (scope == InputScope.Game && _combatScope is { } combat - && _bindings.Find(chord, activation, combat) is { } combatBinding) - return combatBinding; - if (_bindings.Find(chord, activation, scope) is { } binding) - return binding; + if (scope == InputScope.Game && _combatScope is { } combat) + { + Binding[] combatBindings = FindInScope(combat, chord, activation); + if (combatBindings.Length != 0) + return combatBindings; + } + + Binding[] bindings = FindInScope(scope, chord, activation); + if (bindings.Length != 0) + return bindings; } - return null; + return Array.Empty(); } + private Binding[] FindInScope( + InputScope scope, + KeyChord chord, + ActivationType activation) => + _bindings.All + .Where(binding => + binding.Scope == scope + && binding.Chord == chord + && binding.Activation == activation) + .DistinctBy(static binding => binding.Action) + .ToArray(); + /// True iff a is in progress. public bool IsCapturing => _captureCallback is not null; /// - /// Enter modal capture mode. The next non-modifier chord pressed - /// (with whatever modifiers are held at that moment) is reported - /// via and the dispatcher does NOT - /// fire normal action events for that chord. Esc cancels — - /// receives a sentinel - /// default(KeyChord). Modifier-only key transitions - /// (Shift / Ctrl / Alt / Win held alone) are NOT captured; only a - /// non-modifier key down completes capture, so the user can dial - /// in modifier combinations before pressing the trigger key. + /// Enter modal capture mode. The next keyboard key or mouse button + /// (with whatever modifiers are held at that moment) is reported via + /// and the dispatcher does NOT fire normal + /// actions for that chord. Shift/Ctrl/Alt/Win are deferred until key-up: + /// pressing another key first makes them a prefix; releasing the modifier + /// first captures the modifier-only binding. Esc cancels and reports + /// default(KeyChord). /// public void BeginCapture(Action onCaptured) { _captureCallback = onCaptured ?? throw new ArgumentNullException(nameof(onCaptured)); + _captureModifierCandidate = null; } /// @@ -283,6 +348,7 @@ public sealed class InputDispatcher : IDisposable var cb = _captureCallback; if (cb is null) return; _captureCallback = null; + _captureModifierCandidate = null; cb(default); } @@ -440,8 +506,7 @@ public sealed class InputDispatcher : IDisposable if (_heldHoldChords.Count == 0) return; var releases = new List(_heldHoldChords.Count); foreach (KeyChord chord in _heldHoldChords) - if (FindActive(chord, ActivationType.Hold) is { } binding) - releases.Add(binding); + releases.AddRange(FindActiveBindings(chord, ActivationType.Hold)); _heldHoldChords.Clear(); foreach (Binding binding in releases) Fired?.Invoke(binding.Action, ActivationType.Release); @@ -469,9 +534,8 @@ public sealed class InputDispatcher : IDisposable // chord; never dispatch a stale snapshot entry afterward. if (!_heldHoldChords.Contains(chord)) continue; - var hold = FindActive(chord, ActivationType.Hold); - if (hold is not null) - Fired?.Invoke(hold.Value.Action, ActivationType.Hold); + foreach (Binding hold in FindActiveBindings(chord, ActivationType.Hold)) + Fired?.Invoke(hold.Action, ActivationType.Hold); } } @@ -480,50 +544,62 @@ public sealed class InputDispatcher : IDisposable if (Volatile.Read(ref _active) == 0) return; // K.3 modal capture (used by Settings panel's "Rebind" UX) takes // precedence over both WantCaptureKeyboard gating AND normal - // binding lookup. Esc cancels capture; modifier-only keys don't - // complete it (so the user can dial in Shift/Ctrl/Alt before - // pressing the trigger key); every other key completes capture - // with the current modifier state. + // binding lookup. Esc cancels capture. A modifier key is deferred + // until its key-up so it can either become the primary binding by + // itself (retail's walk-mode default) or remain a prefix when the + // user presses a non-modifier key before releasing it. if (_captureCallback is not null) { if (key == Key.Escape) { var cb = _captureCallback; _captureCallback = null; + _captureModifierCandidate = null; cb(default); return; } - if (IsModifierKey(key)) return; // dial more mods, don't complete + if (IsModifierKey(key)) + { + _captureModifierCandidate = key; + return; + } var captured = new KeyChord(key, mods, Device: 0); var cb2 = _captureCallback; _captureCallback = null; + _captureModifierCandidate = null; cb2(captured); return; // SUPPRESS the action — don't run binding lookup below } if (_mouse.WantCaptureKeyboard) return; - var chord = new KeyChord(key, mods, Device: 0); - - var press = FindActive(chord, ActivationType.Press); - if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press); - - var click = FindActive(chord, ActivationType.Click); - if (click is not null) Fired?.Invoke(click.Value.Action, ActivationType.Click); - - var hold = FindActive(chord, ActivationType.Hold); - if (hold is not null) + var chord = KeyboardChord(key, mods); + _currentPhysicalChord = chord; + try { - // Emit a Press transition so subscribers can latch state, then - // record the chord so Tick() will re-fire Hold every frame. - Fired?.Invoke(hold.Value.Action, ActivationType.Press); - _heldHoldChords.Add(chord); + foreach (Binding press in FindActiveBindings(chord, ActivationType.Press)) + Fired?.Invoke(press.Action, ActivationType.Press); + + foreach (Binding click in FindActiveBindings(chord, ActivationType.Click)) + Fired?.Invoke(click.Action, ActivationType.Click); + + IReadOnlyList holds = FindActiveBindings(chord, ActivationType.Hold); + if (holds.Count != 0) + { + // Emit a Press transition so subscribers can latch state, then + // record the chord so Tick() will re-fire Hold every frame. + foreach (Binding hold in holds) + Fired?.Invoke(hold.Action, ActivationType.Press); + _heldHoldChords.Add(chord); + } + } + finally + { + _currentPhysicalChord = null; } } - /// True for Shift/Ctrl/Alt/Win left+right variants — keys - /// that don't complete a capture by themselves. The user holds them - /// to dial in modifier combinations before pressing the trigger key. + /// True for Shift/Ctrl/Alt/Win left+right variants. private static bool IsModifierKey(Key key) => key switch { Key.ShiftLeft or Key.ShiftRight => true, @@ -536,13 +612,24 @@ public sealed class InputDispatcher : IDisposable private void OnKeyUp(Key key, ModifierMask mods) { if (Volatile.Read(ref _active) == 0) return; + if (_captureCallback is not null) + { + if (_captureModifierCandidate == key) + { + Action callback = _captureCallback; + _captureCallback = null; + _captureModifierCandidate = null; + callback(KeyboardChord(key, mods)); + } + return; + } // Release fires regardless of WantCaptureKeyboard so we don't // strand a Hold subscriber in the "held" state if the UI captured // mid-press. - var chord = new KeyChord(key, mods, Device: 0); + var chord = KeyboardChord(key, mods); - var release = FindActive(chord, ActivationType.Release); - if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release); + foreach (Binding release in FindActiveBindings(chord, ActivationType.Release)) + Fired?.Invoke(release.Action, ActivationType.Release); // Any matching Hold binding gets a Release transition. Walk the // tracked set looking for a chord with a matching Key (ignoring @@ -556,8 +643,8 @@ public sealed class InputDispatcher : IDisposable foreach (var held in toRemove) { _heldHoldChords.Remove(held); - var hold = FindActive(held, ActivationType.Hold); - if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release); + foreach (Binding hold in FindActiveBindings(held, ActivationType.Hold)) + Fired?.Invoke(hold.Action, ActivationType.Release); } } @@ -565,16 +652,33 @@ public sealed class InputDispatcher : IDisposable { if (Volatile.Read(ref _active) == 0) return; _mouseClickTravel.Remove(button); + // Retail UIOption_ActionKeyMap captures a QualifiedControl, not merely + // a keyboard scan code. Mouse buttons therefore use the same modal + // capture path and suppress their ordinary action, even while the UI + // owns the pointer for the binding dialog. + if (_captureCallback is not null) + { + var captured = new KeyChord( + MouseButtonToKey(button), + mods, + Device: 1); + Action callback = _captureCallback; + _captureCallback = null; + _captureModifierCandidate = null; + callback(captured); + return; + } if (_mouse.WantCaptureMouse) return; var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1); - var press = FindActive(chord, ActivationType.Press); - if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press); + foreach (Binding press in FindActiveBindings(chord, ActivationType.Press)) + Fired?.Invoke(press.Action, ActivationType.Press); - var hold = FindActive(chord, ActivationType.Hold); - if (hold is not null) + IReadOnlyList holds = FindActiveBindings(chord, ActivationType.Hold); + if (holds.Count != 0) { - Fired?.Invoke(hold.Value.Action, ActivationType.Press); + foreach (Binding hold in holds) + Fired?.Invoke(hold.Action, ActivationType.Press); _heldHoldChords.Add(chord); } @@ -589,8 +693,8 @@ public sealed class InputDispatcher : IDisposable if (_lastMouseDownButton == button && nowMs - _lastMouseDownTickMs <= DoubleClickThresholdMs) { - var dbl = FindActive(chord, ActivationType.DoubleClick); - if (dbl is not null) Fired?.Invoke(dbl.Value.Action, ActivationType.DoubleClick); + foreach (Binding dbl in FindActiveBindings(chord, ActivationType.DoubleClick)) + Fired?.Invoke(dbl.Action, ActivationType.DoubleClick); _lastMouseDownButton = null; // consumed; require fresh pair for next } else @@ -606,8 +710,8 @@ public sealed class InputDispatcher : IDisposable var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1); bool wasClickCandidate = _mouseClickTravel.Remove(button, out float travel); - var release = FindActive(chord, ActivationType.Release); - if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release); + foreach (Binding release in FindActiveBindings(chord, ActivationType.Release)) + Fired?.Invoke(release.Action, ActivationType.Release); var keyForLookup = MouseButtonToKey(button); var toRemove = new List(); @@ -619,16 +723,17 @@ public sealed class InputDispatcher : IDisposable foreach (var held in toRemove) { _heldHoldChords.Remove(held); - var hold = FindActive(held, ActivationType.Hold); - if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release); + foreach (Binding hold in FindActiveBindings(held, ActivationType.Hold)) + Fired?.Invoke(hold.Action, ActivationType.Release); } if (wasClickCandidate && !_mouse.WantCaptureMouse && travel <= ClickDragThresholdPixels - && FindActive(chord, ActivationType.Click) is { } click) + && FindActiveBindings(chord, ActivationType.Click) is { Count: > 0 } clicks) { - Fired?.Invoke(click.Action, ActivationType.Click); + foreach (Binding click in clicks) + Fired?.Invoke(click.Action, ActivationType.Click); } } @@ -683,6 +788,25 @@ public sealed class InputDispatcher : IDisposable _ => (Key)(-1000 - (int)button), }; + /// + /// Silk includes a modifier key's own bit in the event modifier mask; + /// retail QualifiedControl stores a bare DIK_LSHIFT/LCONTROL/LMENU with + /// metamode zero. Remove only the primary key's self bit so persisted and + /// displayed chords remain byte-faithful while combinations stay exact. + /// + private static KeyChord KeyboardChord(Key key, ModifierMask modifiers) + { + modifiers &= key switch + { + Key.ShiftLeft or Key.ShiftRight => ~ModifierMask.Shift, + Key.ControlLeft or Key.ControlRight => ~ModifierMask.Ctrl, + Key.AltLeft or Key.AltRight => ~ModifierMask.Alt, + Key.SuperLeft or Key.SuperRight => ~ModifierMask.Win, + _ => ~ModifierMask.None, + }; + return new KeyChord(key, modifiers, Device: 0); + } + private List DetachSources() { var failures = new List(); diff --git a/src/AcDream.UI.Abstractions/Input/InputScope.cs b/src/AcDream.UI.Abstractions/Input/InputScope.cs index 857f3816..230312ed 100644 --- a/src/AcDream.UI.Abstractions/Input/InputScope.cs +++ b/src/AcDream.UI.Abstractions/Input/InputScope.cs @@ -8,11 +8,8 @@ namespace AcDream.UI.Abstractions.Input; /// sits at the bottom of the stack and catches global chords like /// Esc / F1 that should fire regardless of focus. /// -/// -/// K.1a defines the enum but only pushes + -/// by default. Combat scopes light up in Phase L -/// when CombatState.CurrentMode tracking lands. -/// +/// Combat scope follows the live retail combat mode; modal/edit/chat +/// scopes are pushed above it as their authored surfaces activate. /// public enum InputScope { @@ -30,13 +27,13 @@ public enum InputScope /// A modal dialog is open and capturing input. Dialog, /// Combat with melee weapon equipped — Insert/PgUp/Delete/End/PgDn - /// remap to power + attack-level. Dormant until Phase L. + /// remap to power + attack-level. MeleeCombat, /// Combat with missile weapon equipped — Insert/PgUp/Delete/End/PgDn - /// remap to accuracy + aim-level. Dormant until Phase L. + /// remap to accuracy + aim-level. MissileCombat, /// Magic mode — 1-9 cast UseSpellSlot; Insert/PgUp etc. - /// page through spell tabs. Dormant until Phase L. + /// page through spell tabs. MagicCombat, /// Camera alternate mode (F2 / Numpad-/) — arrow keys rotate /// the camera instead of the character. diff --git a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs index 52a55e5c..c0d79d27 100644 --- a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs +++ b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs @@ -10,9 +10,9 @@ namespace AcDream.UI.Abstractions.Input; /// /// Mutable collection of s. Owns lookup by chord /// (for the dispatcher) and lookup by action (for the Settings UI). -/// Insertion-order preserved — first-match-wins on lookup, so a user -/// can add a custom binding ahead of a default and have it take effect -/// without removing the default. +/// Insertion order is preserved. Direct +/// queries return the first match; emits every +/// distinct action in the highest-priority active retail input map. /// /// /// K.1c: now returns the full retail-faithful @@ -26,7 +26,7 @@ namespace AcDream.UI.Abstractions.Input; /// public sealed class KeyBindings { - private const int CurrentSchemaVersion = 5; + private const int CurrentSchemaVersion = 7; private readonly List _bindings = new(); @@ -162,15 +162,9 @@ public sealed class KeyBindings b.Add(new(new KeyChord(Key.C, ModifierMask.None), InputAction.MovementStrafeRight)); b.Add(new(new KeyChord(Key.D, ModifierMask.Alt), InputAction.MovementStrafeRight)); b.Add(new(new KeyChord(Key.Right, ModifierMask.Alt), InputAction.MovementStrafeRight)); - // Walk-mode modifier — Hold so a subscriber can latch state on - // press and unlatch on release. K-fix1 (2026-04-26): the chord - // modifier MUST be Shift, not None — when LShift/RShift is the - // primary key the OS keyboard reports CurrentModifiers=Shift - // alongside the key-down. Bind both left + right shift to match. - // This is the same pattern AcdreamCurrentDefaults uses for its - // Shift→RunLock binding (see lines 98-99 above). - b.Add(new(new KeyChord(Key.ShiftLeft, ModifierMask.Shift), InputAction.MovementWalkMode, ActivationType.Hold)); - b.Add(new(new KeyChord(Key.ShiftRight, ModifierMask.Shift), InputAction.MovementWalkMode, ActivationType.Hold)); + // Retail authors exactly bare DIK_LSHIFT. InputDispatcher normalizes + // Silk's self-reported Shift modifier bit at the physical boundary. + b.Add(new(new KeyChord(Key.ShiftLeft, ModifierMask.None), InputAction.MovementWalkMode, ActivationType.Hold)); b.Add(new(new KeyChord(Key.Q, ModifierMask.None), InputAction.MovementRunLock)); b.Add(new(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementStop)); b.Add(new(new KeyChord(Key.Y, ModifierMask.None), InputAction.Ready)); @@ -180,7 +174,10 @@ public sealed class KeyBindings b.Add(new(new KeyChord(Key.Space, ModifierMask.None), InputAction.MovementJump)); // ── ItemSelectionCommands ────────────────────────────── - b.Add(new(new KeyChord(Key.F, ModifierMask.None), InputAction.SelectionPickUp)); + // Retail action 0x1000002C: F places the selected object in the + // inventory. The old SelectionPickUp alias had no ActionMap row and + // made Configure Keyboard show the F default on the wrong command. + b.Add(new(new KeyChord(Key.F, ModifierMask.None), InputAction.SelectionPlaceInInventory)); b.Add(new(new KeyChord(Key.T, ModifierMask.None), InputAction.SelectionSplitStack)); b.Add(new(new KeyChord(Key.P, ModifierMask.None), InputAction.SelectionPreviousSelection)); b.Add(new(new KeyChord(Key.Backspace, ModifierMask.None), InputAction.SelectionClosestCompassItem)); @@ -223,20 +220,21 @@ public sealed class KeyBindings b.Add(new(new KeyChord(Key.Escape, ModifierMask.Shift), InputAction.LOGOUT)); // ── QuickslotCommands ────────────────────────────────── - // Retail gmToolbarUI::ListenToGlobalMessage @ 0x004BE4E0 receives - // distinct action-id ranges: bare 1..9 USE slots 0..8, while - // Ctrl+1..9 SELECT those slots. The keymap repeats the display name - // UseQuickSlot_N for both bindings, so our semantic action layer must - // preserve the differing intent explicitly. + // Retail's MasterInputMap binds both bare N and Ctrl+N to the SAME + // UseQuickSlot_N action ids (0x10000042..4A). The separate Select + // Quickslot action ids (0x1000004E..56) have no default chords. for (int i = 1; i <= 9; i++) { var k = (Key)((int)Key.Number0 + i); // Number1..Number9 var useAction = (InputAction)((int)InputAction.UseQuickSlot_1 + i - 1); - var selectAction = (InputAction)((int)InputAction.SelectQuickSlot_1 + i - 1); b.Add(new(new KeyChord(k, ModifierMask.None), useAction)); - b.Add(new(new KeyChord(k, ModifierMask.Ctrl), selectAction)); + b.Add(new(new KeyChord(k, ModifierMask.Ctrl), useAction)); } - // Alt+5..9 → UseQuickSlot_14..18. + // Alt+1..4 → slots 10..13; Alt+5..9 → slots 14..18. + b.Add(new(new KeyChord(Key.Number1, ModifierMask.Alt), InputAction.UseQuickSlot_10)); + b.Add(new(new KeyChord(Key.Number2, ModifierMask.Alt), InputAction.UseQuickSlot_11)); + b.Add(new(new KeyChord(Key.Number3, ModifierMask.Alt), InputAction.UseQuickSlot_12)); + b.Add(new(new KeyChord(Key.Number4, ModifierMask.Alt), InputAction.UseQuickSlot_13)); for (int i = 5; i <= 9; i++) { var k = (Key)((int)Key.Number0 + i); @@ -250,7 +248,7 @@ public sealed class KeyBindings b.Add(new(new KeyChord(Key.Tab, ModifierMask.None), InputAction.ToggleChatEntry)); b.Add(new(new KeyChord(Key.Enter, ModifierMask.None), InputAction.EnterChatMode)); - // ── Combat (mode-dependent — dormant in K, lights up in Phase L) ── + // ── Combat (mode-dependent retail scopes) ── b.Add(new(new KeyChord(Key.GraveAccent, ModifierMask.None), InputAction.CombatToggleCombat)); // Melee mode (active when MeleeCombat scope pushed). b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseAttackPower, Scope: InputScope.MeleeCombat)); @@ -261,15 +259,13 @@ public sealed class KeyBindings b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatLowAttack, ActivationType.Hold, InputScope.MeleeCombat)); b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatMediumAttack, ActivationType.Hold, InputScope.MeleeCombat)); b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatHighAttack, ActivationType.Hold, InputScope.MeleeCombat)); - // Missile + Magic + Spell-tab — same chords; resolved by scope at - // runtime per InputDispatcher's stack lookup. Add the bindings; - // subscribers arrive in Phase L when CombatState.CurrentMode is - // wired. + // Missile + Magic + Spell-tab — same chords; resolved by the live + // combat scope through InputDispatcher's stack lookup. b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseMissileAccuracy, Scope: InputScope.MissileCombat)); b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseMissileAccuracy, Scope: InputScope.MissileCombat)); - b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatAimLow, Scope: InputScope.MissileCombat)); - b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatAimMedium, Scope: InputScope.MissileCombat)); - b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatAimHigh, Scope: InputScope.MissileCombat)); + b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatAimLow, ActivationType.Hold, InputScope.MissileCombat)); + b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatAimMedium, ActivationType.Hold, InputScope.MissileCombat)); + b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatAimHigh, ActivationType.Hold, InputScope.MissileCombat)); b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatPrevSpellTab, Scope: InputScope.MagicCombat)); b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatNextSpellTab, Scope: InputScope.MagicCombat)); b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatPrevSpell, Scope: InputScope.MagicCombat)); @@ -294,8 +290,8 @@ public sealed class KeyBindings b.Add(new(new KeyChord(Key.K, ModifierMask.None), InputAction.PointState)); // ── Camera ───────────────────────────────────────────── - b.Add(new(new KeyChord(Key.KeypadDivide, ModifierMask.None), InputAction.CameraActivateAlternateMode)); - b.Add(new(new KeyChord(Key.F2, ModifierMask.None), InputAction.CameraActivateAlternateMode)); + b.Add(new(new KeyChord(Key.KeypadDivide, ModifierMask.None), InputAction.CameraActivateAlternateMode, ActivationType.Hold)); + b.Add(new(new KeyChord(Key.F2, ModifierMask.None), InputAction.CameraActivateAlternateMode, ActivationType.Hold)); // CameraInstantMouseLook (MMB hold) — encoded as a mouse chord // via the K.1a Device=1 convention. K.2 lights up the actual // camera+yaw drive logic. @@ -304,16 +300,22 @@ public sealed class KeyBindings InputAction.CameraInstantMouseLook, ActivationType.Hold)); // Numpad cluster. - b.Add(new(new KeyChord(Key.Keypad4, ModifierMask.None), InputAction.CameraRotateLeft)); - b.Add(new(new KeyChord(Key.Keypad6, ModifierMask.None), InputAction.CameraRotateRight)); - b.Add(new(new KeyChord(Key.Keypad8, ModifierMask.None), InputAction.CameraRotateUp)); - b.Add(new(new KeyChord(Key.Keypad2, ModifierMask.None), InputAction.CameraRotateDown)); - b.Add(new(new KeyChord(Key.KeypadSubtract, ModifierMask.None), InputAction.CameraMoveToward)); - b.Add(new(new KeyChord(Key.KeypadAdd, ModifierMask.None), InputAction.CameraMoveAway)); + b.Add(new(new KeyChord(Key.Keypad4, ModifierMask.None), InputAction.CameraRotateLeft, ActivationType.Hold)); + b.Add(new(new KeyChord(Key.Keypad6, ModifierMask.None), InputAction.CameraRotateRight, ActivationType.Hold)); + b.Add(new(new KeyChord(Key.Keypad8, ModifierMask.None), InputAction.CameraRotateUp, ActivationType.Hold)); + b.Add(new(new KeyChord(Key.Keypad2, ModifierMask.None), InputAction.CameraRotateDown, ActivationType.Hold)); + b.Add(new(new KeyChord(Key.KeypadSubtract, ModifierMask.None), InputAction.CameraMoveToward, ActivationType.Hold)); + b.Add(new(new KeyChord(Key.KeypadAdd, ModifierMask.None), InputAction.CameraMoveAway, ActivationType.Hold)); b.Add(new(new KeyChord(Key.Keypad0, ModifierMask.None), InputAction.CameraViewDefault)); b.Add(new(new KeyChord(Key.KeypadDecimal, ModifierMask.None), InputAction.CameraViewFirstPerson)); b.Add(new(new KeyChord(Key.Keypad5, ModifierMask.None), InputAction.CameraViewLookDown)); b.Add(new(new KeyChord(Key.KeypadEnter, ModifierMask.None), InputAction.CameraViewMapMode)); + // CameraAlternateControls is a separate retail InputMap. Its arrow + // defaults must not alias the movement/camera-primary row identities. + b.Add(new(new KeyChord(Key.Left, ModifierMask.None), InputAction.CameraAlternateRotateLeft, ActivationType.Hold, InputScope.Camera)); + b.Add(new(new KeyChord(Key.Right, ModifierMask.None), InputAction.CameraAlternateRotateRight, ActivationType.Hold, InputScope.Camera)); + b.Add(new(new KeyChord(Key.Up, ModifierMask.None), InputAction.CameraAlternateRotateUp, ActivationType.Hold, InputScope.Camera)); + b.Add(new(new KeyChord(Key.Down, ModifierMask.None), InputAction.CameraAlternateRotateDown, ActivationType.Hold, InputScope.Camera)); // ── Mouse selection ──────────────────────────────────── // Retail keymap: SelectLeft = LMB, SelectRight = RMB, SelectMid = MMB, @@ -413,6 +415,7 @@ public sealed class KeyBindings var defaults = RetailDefaults(); var loaded = new KeyBindings(); + var explicitlyStoredActions = new HashSet(); if (root.TryGetProperty("actions", out var actionsEl) && actionsEl.ValueKind == JsonValueKind.Object) @@ -422,6 +425,7 @@ public sealed class KeyBindings if (!Enum.TryParse(actionProp.Name, out var action)) continue; // unknown action → skip if (actionProp.Value.ValueKind != JsonValueKind.Array) continue; + explicitlyStoredActions.Add(action); foreach (var bindingEl in actionProp.Value.EnumerateArray()) { if (!bindingEl.TryGetProperty("key", out var keyEl)) continue; @@ -444,7 +448,11 @@ public sealed class KeyBindings device = (byte)dEl.GetInt32(); } var chord = new KeyChord(silkKey, mods, device); - action = MigrateLegacyQuickSlotIntent(version, action, chord, activation); + action = MigrateQuickSlotIntent(version, action, chord, activation); + // A migrated action is still an explicit user entry. + // Without this, the default-merge pass below appends the + // retail default beside the migrated custom chord. + explicitlyStoredActions.Add(action); activation = MigrateCombatAttackActivation(version, action, activation); activation = MigrateSelectRightActivation(version, action, activation); InputScope scope = defaults.ForAction(action) @@ -468,7 +476,7 @@ public sealed class KeyBindings // newly-added actions if the user file is older. foreach (var actionInDefaults in Enum.GetValues()) { - if (!loaded.ForAction(actionInDefaults).Any() + if (!explicitlyStoredActions.Contains(actionInDefaults) && defaults.ForAction(actionInDefaults).Any()) { foreach (var def in defaults.ForAction(actionInDefaults)) @@ -496,6 +504,12 @@ public sealed class KeyBindings if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); var actions = new SortedDictionary>(StringComparer.Ordinal); + // An empty array is meaningful: the player explicitly cleared all + // three GUI slots for this retail action. Earlier schemas omitted the + // property entirely, so the next launch mistook "unbound" for "new + // action missing from an old file" and silently restored its default. + foreach (InputAction action in RetailActionIdentityTable.Map.Values) + actions.TryAdd(action.ToString(), new List()); foreach (var binding in _bindings) { if (!actions.TryGetValue(binding.Action.ToString(), out var list)) @@ -528,30 +542,31 @@ public sealed class KeyBindings } /// - /// Schema v1 repeated UseQuickSlot_N for bare and Ctrl chords, - /// losing retail's use-vs-select distinction. Migrate only the exact old - /// default Ctrl+matching-number shape; arbitrary user rebindings remain - /// attached to the action the user chose. + /// Schema v2-v5 incorrectly rewrote retail's Ctrl+1..9 + /// UseQuickSlot_N defaults to SelectQuickSlot_N. The 2013 + /// MasterInputMap and gmToolbarUI::ListenToGlobalMessage both prove + /// Ctrl+N sends the same use action as bare N. Repair only that exact old + /// generated-default shape; arbitrary SelectQuickSlot rebindings remain. /// - private static InputAction MigrateLegacyQuickSlotIntent( + private static InputAction MigrateQuickSlotIntent( int version, InputAction action, KeyChord chord, ActivationType activation) { - if (version >= 2 + if (version >= 6 || activation != ActivationType.Press || chord.Device != 0 || chord.Modifiers != ModifierMask.Ctrl) return action; - int offset = (int)action - (int)InputAction.UseQuickSlot_1; + int offset = (int)action - (int)InputAction.SelectQuickSlot_1; if ((uint)offset >= 9u) return action; var expectedKey = (Key)((int)Key.Number1 + offset); return chord.Key == expectedKey - ? (InputAction)((int)InputAction.SelectQuickSlot_1 + offset) + ? (InputAction)((int)InputAction.UseQuickSlot_1 + offset) : action; } diff --git a/src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs b/src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs index 1e3b6223..066c9bcd 100644 --- a/src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs +++ b/src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs @@ -1,72 +1,138 @@ using System.Collections.Generic; +using System.Linq; namespace AcDream.UI.Abstractions.Input; /// /// Campaign OP slice OP8: maps a retail DAT ActionMap row — the /// (InputMap id, Action id) pair AcDream.Core.Input.RetailActionMapRow -/// carries — to acdream's own , when one exists. +/// carries — to acdream's own . /// /// -/// Why this table exists. The DAT ActionMap singleton (empirically dumped -/// 2026-08-11, see AcDream.Core.Input.RetailActionMap's class doc) carries 306 -/// user-bindable rows. — the enum every OTHER acdream input -/// path (live dispatch, KeyBindings, InputDispatcher) already keys on — -/// has roughly half that many members, because it was authored around "what acdream -/// currently implements" (K.1a/K.1c), not "every action the 2013 client's keymap -/// screen can show." Two categories are the biggest gaps: 82 of the DAT's 87 Emote -/// rows have no acdream animation dispatch yet (only 5 are wired: Cry/Laugh/Cheer/ -/// Wave/PointState — exactly the 5 that happen to carry retail default keys), and all -/// 48 CharacterSettings rows are hotkeys for the SAME PlayerOption/ -/// CharacterOptions preference bits OP1's CharacterOptionTable and OP4's -/// Character-tab checkboxes already model — wiring "press this key, flip that same -/// server-synced bit" is a real feature (a hotkey-to-option-toggle dispatcher) that -/// does not exist yet anywhere in acdream and is out of scope for this slice (see the -/// OP8 register row). +/// The DAT ActionMap singleton carries exactly 306 user-bindable rows. Campaign KB +/// gives every row one distinct live identity. Identity is the full +/// (InputMapId, ActionId) pair: retail legitimately reuses action ids between +/// CameraControls and CameraAlternateControls, and collapsing those rows would make +/// one GUI rebind silently overwrite the other. /// /// /// -/// Every mapping below was verified two ways before being added: (1) the DAT's +/// Every mapping below was verified two ways: (1) the DAT's /// resolved English label/tooltip unambiguously names the SAME action as the /// member's own XML doc, AND (2) where the retail default /// key(s) for that DAT row are non-empty, they match /// 's existing chord(s) for the candidate -/// (byte-verified 2026-08-11 against the installed dats — -/// see RetailActionMapReaderTests.LiveDatTests and this slice's -/// RetailActionIdentityRoundTripTests). A DAT row that could not be verified -/// BOTH ways is left OUT of this table on purpose — it renders on the Configure -/// Keyboard screen as a real, bindable, persisted row (see -/// KeyboardConfigController), it just does not yet reach any live acdream -/// consumer. Silently guessing a wrong mapping would misroute a user's rebind to the -/// WRONG gameplay action, which is worse than an honest "not wired yet." -/// -/// -/// -/// Known gaps deliberately left unmapped (register row, OP8): -/// Spell Slot 10/11/12 (ctx 0x10000005, DAT actions 0x6E/0x6F/0x70 — -/// only defines UseSpellSlot_1..9); Quickslot -/// 10/11/12/13 (ctx 0x1000000C, DAT actions 0x1000004B/4C/4D/10000132 — -/// 's UseQuickSlot_* family jumps from 9 straight to -/// 14, a pre-existing enum gap this slice did not introduce and does not fix); every -/// CharacterSettings row (ctx 0x10000008, all 48); 82 of 87 Emote rows (ctx -/// 0x10000006); all 10 CameraAlternateControls rows (ctx 0x6 — the M2 -/// de-alias carve-out, see the mapping table's own comment); and roughly half of the -/// UI-class rows (ctx 0x10000007/0x10000009 — panels acdream has no -/// toggle for, e.g. Vitae, Link Status, House, Map, Character Info, the -/// positive/negative Magic panels). +/// . The installed-DAT conformance test requires complete, +/// injective 306/306 coverage, so a future DAT drift cannot quietly recreate the +/// former dim/store-only tier. /// /// public static class RetailActionIdentityTable { /// (InputMap id, Action id) → the acdream that - /// owns live dispatch for it. A DAT row whose key is absent has no acdream - /// consumer yet. + /// owns live dispatch for it. public static readonly IReadOnlyDictionary<(uint InputMapId, uint ActionId), InputAction> Map = BuildTable(); public static bool TryResolve(uint inputMapId, uint actionId, out InputAction action) => Map.TryGetValue((inputMapId, actionId), out action); + /// Inverse identity used by family routers and conformance checks. + public static readonly IReadOnlyDictionary ReverseMap = + Map.ToDictionary(static pair => pair.Value, static pair => pair.Key); + + public static bool TryGetRetailIdentity( + InputAction action, + out (uint InputMapId, uint ActionId) identity) => + ReverseMap.TryGetValue(action, out identity); + + /// Live dispatch scope implied by the retail InputMap context. + public static InputScope ScopeForInputMap(uint inputMapId) => inputMapId switch + { + 0x00000006u => InputScope.Camera, + 0x10000003u => InputScope.MeleeCombat, + 0x10000004u => InputScope.MissileCombat, + 0x10000005u => InputScope.MagicCombat, + _ => InputScope.Game, + }; + + /// + /// Retail action delivery shape for a DAT ActionMap row. Continuous + /// movement/camera actions receive start and stop edges; the remaining + /// rows are one-shot presses unless a concrete retail consumer declares + /// otherwise in . + /// + public static ActivationType ActivationFor(uint inputMapId, uint actionId) + { + if (inputMapId == 0x4u && actionId == 0x32u) + return ActivationType.Hold; + + if (inputMapId is 0x5u or 0x6u + && actionId is >= 0x33u and <= 0x38u) + { + return ActivationType.Hold; + } + + if (inputMapId == 0x5u && actionId is 0x3Du or 0x3Eu) + return ActivationType.Hold; + + // ClientCombatSystem::HandleCombatAction @ 0x0056D600 sends both + // melee 0x5D-0x5F and missile 0xF1-0xF3 through Begin/EndAttackRequest. + if (inputMapId == 0x10000003u + && actionId is >= 0x1000005Du and <= 0x1000005Fu) + { + return ActivationType.Hold; + } + + if (inputMapId == 0x10000004u + && actionId is >= 0x100000F1u and <= 0x100000F3u) + { + return ActivationType.Hold; + } + + return ActivationType.Press; + } + + /// + /// Maps a CharacterSettings hotkey identity to retail's linear + /// PlayerOption id. The five 2013 options absent from ActionMap remain + /// configurable through the Character page, but correctly have no row + /// here. + /// + public static bool TryGetCharacterOptionId(InputAction action, out uint optionId) + { + optionId = 0u; + if (!TryGetRetailIdentity(action, out var identity) + || identity.InputMapId != 0x10000008u) + { + return false; + } + + optionId = identity.ActionId switch + { + >= 0x10000071u and <= 0x10000074u => identity.ActionId - 0x10000071u, + >= 0x10000076u and <= 0x10000083u => identity.ActionId - 0x10000071u, + >= 0x10000085u and <= 0x10000093u => identity.ActionId - 0x10000071u, + 0x1000010Eu => 0x23u, + 0x1000010Fu => 0x24u, + 0x10000110u => 0x25u, + 0x10000112u => 0x26u, + 0x1000011Bu => 0x28u, + 0x1000011Du => 0x29u, + 0x1000011Eu => 0x2Au, + 0x1000011Fu => 0x2Bu, + 0x10000120u => 0x2Cu, + 0x10000123u => 0x2Du, + 0x10000125u => 0x2Eu, + 0x1000012Au => 0x2Fu, + 0x1000012Cu => 0x30u, + 0x1000012Fu => 0x32u, + 0x1000013Eu => 0x13u, + _ => uint.MaxValue, + }; + return optionId != uint.MaxValue; + } + private static Dictionary<(uint, uint), InputAction> BuildTable() { var t = new Dictionary<(uint, uint), InputAction>(); @@ -89,24 +155,9 @@ public static class RetailActionIdentityTable M(0x4, 0x10000097, InputAction.Sleeping); // ── CameraControls (ctx 0x5) — 12/12. ────────────────────────── - // M2 REWORK (2026-08-11 review): CameraControls (ctx 0x5, the - // Numpad-default scheme RetailDefaults() actually carries) and - // CameraAlternateControls (ctx 0x6, the arrow-key alternate scheme - // RetailDefaults() never had — see - // RetailActionIdentityRoundTripTests' now-retired camera allowlist - // entries) were both previously mapped to the SAME InputAction. - // KeyBindings/Binding has no "which scheme" tag, and SetForAction is - // whole-action replacement, so the two rows aliased one live target: - // both showed identical (stale) chords, rebinding one silently wiped - // the other, and a row could conflict with its own twin. Building - // real per-scheme dual-binding storage (or ten new InputAction - // members plus the camera-dispatch code to consume them) is a real - // feature, not a one-line fix, and out of scope for this rework. Only - // ctx 0x5 — the scheme that already has a live, verified - // RetailDefaults() presence — maps here; ctx 0x6 falls through to the - // generic unmapped/store-only path below (AP-203), fully renderable, - // bindable and persisted, honestly carrying no live effect, exactly - // like every other unmapped row. + // The primary and alternate maps deliberately use distinct actions. + // Retail reuses the ten low action ids, but they are separate rows and + // separate rebind targets; collapsing them aliases GUI state. M(0x5, 0x33, InputAction.CameraMoveToward); M(0x5, 0x34, InputAction.CameraMoveAway); M(0x5, 0x35, InputAction.CameraRotateLeft); @@ -120,6 +171,18 @@ public static class RetailActionIdentityTable M(0x5, 0x3D, InputAction.CameraInstantMouseLook); M(0x5, 0x3E, InputAction.CameraActivateAlternateMode); + // ── CameraAlternateControls (ctx 0x6) — 10/10. ───────────── + M(0x6, 0x33, InputAction.CameraAlternateMoveToward); + M(0x6, 0x34, InputAction.CameraAlternateMoveAway); + M(0x6, 0x35, InputAction.CameraAlternateRotateLeft); + M(0x6, 0x36, InputAction.CameraAlternateRotateRight); + M(0x6, 0x37, InputAction.CameraAlternateRotateUp); + M(0x6, 0x38, InputAction.CameraAlternateRotateDown); + M(0x6, 0x39, InputAction.CameraAlternateViewDefault); + M(0x6, 0x3A, InputAction.CameraAlternateViewFirstPerson); + M(0x6, 0x3B, InputAction.CameraAlternateViewLookDown); + M(0x6, 0x3C, InputAction.CameraAlternateViewMapMode); + // ── Combat (ctx 0x10000002) — 1/1. ───────────────────────────── M(0x10000002, 0x1000005A, InputAction.CombatToggleCombat); @@ -137,8 +200,7 @@ public static class RetailActionIdentityTable M(0x10000004, 0x100000F2, InputAction.CombatAimMedium); M(0x10000004, 0x100000F3, InputAction.CombatAimHigh); - // ── MagicCombat (ctx 0x10000005) — 18/21 (Spell Slot 10/11/12 have - // no InputAction — register row). ──────────────────────────── + // ── MagicCombat (ctx 0x10000005) — 21/21. ────────────────────── M(0x10000005, 0x10000060, InputAction.CombatCastCurrentSpell); M(0x10000005, 0x10000061, InputAction.CombatPrevSpell); M(0x10000005, 0x10000062, InputAction.CombatNextSpell); @@ -153,21 +215,111 @@ public static class RetailActionIdentityTable M(0x10000005, 0x1000006B, InputAction.UseSpellSlot_7); M(0x10000005, 0x1000006C, InputAction.UseSpellSlot_8); M(0x10000005, 0x1000006D, InputAction.UseSpellSlot_9); - // 0x6E/0x6F/0x70 (Spell Slot 10/11/12) — no InputAction. Unmapped. + M(0x10000005, 0x1000006E, InputAction.UseSpellSlot_10); + M(0x10000005, 0x1000006F, InputAction.UseSpellSlot_11); + M(0x10000005, 0x10000070, InputAction.UseSpellSlot_12); M(0x10000005, 0x10000102, InputAction.CombatFirstSpell); M(0x10000005, 0x10000103, InputAction.CombatLastSpell); M(0x10000005, 0x10000104, InputAction.CombatFirstSpellTab); M(0x10000005, 0x10000105, InputAction.CombatLastSpellTab); - // ── Emotes (ctx 0x10000006) — 5/87 (the only 5 acdream dispatches - // an animation for; also the only 5 with retail default keys). ── - M(0x10000006, 0x100000A2, InputAction.Cheer); - M(0x10000006, 0x100000A7, InputAction.Cry); - M(0x10000006, 0x100000B2, InputAction.Laugh); - M(0x10000006, 0x100000BE, InputAction.PointState); - M(0x10000006, 0x100000E5, InputAction.Wave); + // ── Emotes (ctx 0x10000006) — 87/87. ──────────────────────── + InputAction[] emotes = + { + InputAction.EmoteAfkState, + InputAction.EmoteAkimbo, + InputAction.EmoteAToyotState, + InputAction.EmoteAkimboState, + InputAction.EmoteAtEaseState, + InputAction.EmoteBeckon, + InputAction.EmoteBeSeeingYou, + InputAction.EmoteBlowKiss, + InputAction.EmoteBowDeep, + InputAction.EmoteBowDeepState, + InputAction.Cheer, + InputAction.EmoteClapHands, + InputAction.EmoteClapHandsState, + InputAction.EmoteCringe, + InputAction.EmoteCrossArmsState, + InputAction.Cry, + InputAction.EmoteCurtseyState, + InputAction.EmoteDrudgeDance, + InputAction.EmoteDrudgeDanceState, + InputAction.EmoteHaveASeat, + InputAction.EmoteHaveASeatState, + InputAction.EmoteHeartyLaugh, + InputAction.EmoteHelper, + InputAction.EmoteKneel, + InputAction.EmoteKneelState, + InputAction.EmoteKnock, + InputAction.Laugh, + InputAction.EmoteLeanState, + InputAction.EmoteMeditateState, + InputAction.EmoteMimeDrinking, + InputAction.EmoteMimeEating, + InputAction.EmoteMock, + InputAction.EmoteNod, + InputAction.EmoteNudgeLeft, + InputAction.EmoteNudgeRight, + InputAction.EmotePlead, + InputAction.EmotePleadState, + InputAction.EmotePoint, + InputAction.PointState, + InputAction.EmotePointDown, + InputAction.EmotePointDownState, + InputAction.EmotePointLeft, + InputAction.EmotePointLeftState, + InputAction.EmotePointRight, + InputAction.EmotePointRightState, + InputAction.EmotePossumState, + InputAction.EmotePray, + InputAction.EmotePrayState, + InputAction.EmoteReadState, + InputAction.EmoteSalute, + InputAction.EmoteSaluteState, + InputAction.EmoteScanHorizon, + InputAction.EmoteScratchHead, + InputAction.EmoteScratchHeadState, + InputAction.EmoteShakeFist, + InputAction.EmoteShakeFistState, + InputAction.EmoteShakeHead, + InputAction.EmoteShiver, + InputAction.EmoteShiverState, + InputAction.EmoteShoo, + InputAction.EmoteShrug, + InputAction.EmoteSitState, + InputAction.EmoteSitBackState, + InputAction.EmoteSitCrossleggedState, + InputAction.EmoteSlouch, + InputAction.EmoteSlouchState, + InputAction.EmoteSmackHead, + InputAction.EmoteSnowAngelState, + InputAction.EmoteSpit, + InputAction.EmoteSurrender, + InputAction.EmoteSurrenderState, + InputAction.EmoteTalkToTheHandState, + InputAction.EmoteTapFoot, + InputAction.EmoteTapFootState, + InputAction.EmoteTeapot, + InputAction.EmoteThinkerState, + InputAction.EmoteWarmHands, + InputAction.Wave, + InputAction.EmoteWaveState, + InputAction.EmoteWaveLow, + InputAction.EmoteWaveHigh, + InputAction.EmoteWinded, + InputAction.EmoteWindedState, + InputAction.EmoteWoah, + InputAction.EmoteWoahState, + InputAction.EmoteYawnAndStretch, + InputAction.EmoteYmca, + }; + for (int i = 0; i < emotes.Length; i++) + M(0x10000006, 0x10000098u + (uint)i, emotes[i]); - // ── ItemSelectionCommands (ctx 0x10000007) — 17/26. ──────────── + // ── ItemSelectionCommands (ctx 0x10000007) — 26/26. ──────────── + M(0x10000007, 0x1000002A, InputAction.SelectionSelf); + M(0x10000007, 0x1000002C, InputAction.SelectionPlaceInInventory); M(0x10000007, 0x1000002D, InputAction.SelectionSplitStack); M(0x10000007, 0x1000002E, InputAction.SelectionPreviousSelection); M(0x10000007, 0x1000002F, InputAction.SelectionClosestCompassItem); @@ -185,20 +337,44 @@ public static class RetailActionIdentityTable M(0x10000007, 0x1000003B, InputAction.SelectionNextPlayer); M(0x10000007, 0x1000003C, InputAction.SelectionPreviousFellow); M(0x10000007, 0x1000003D, InputAction.SelectionNextFellow); + M(0x10000007, 0x1000003E, InputAction.SelectionUseClosestUnopenedCorpse); + M(0x10000007, 0x1000003F, InputAction.SelectionUseNextUnopenedCorpse); + M(0x10000007, 0x10000040, InputAction.SelectionGiveToTarget); + M(0x10000007, 0x10000041, InputAction.SelectionDrop); + M(0x10000007, 0x1000011C, InputAction.SelectionPlaceInMainPack); + M(0x10000007, 0x10000121, InputAction.SelectionClosestUnopenedCorpse); + M(0x10000007, 0x10000122, InputAction.SelectionNextUnopenedCorpse); - // ── UICommands (ctx 0x10000009) — 22/42. ─────────────────────── + // ── UICommands (ctx 0x10000009) — 42/42. ─────────────────────── M(0x10000009, 0x55, InputAction.CaptureScreenshot); M(0x10000009, 0x7B, InputAction.ToggleHelp); M(0x10000009, 0x7C, InputAction.TogglePluginManager); + M(0x10000009, 0x10000003, InputAction.ToggleAbuseReportingPanel); + M(0x10000009, 0x10000005, InputAction.ToggleCharacterInfoPanel); + M(0x10000009, 0x10000006, InputAction.TogglePositiveMagicPanel); + M(0x10000009, 0x10000007, InputAction.ToggleNegativeMagicPanel); + M(0x10000009, 0x10000009, InputAction.ToggleLinkStatusPanel); + M(0x10000009, 0x1000000B, InputAction.ToggleUrgentAssistancePanel); + M(0x10000009, 0x1000000C, InputAction.ToggleVitaePanel); + M(0x10000009, 0x1000000D, InputAction.ToggleSocialPanel); M(0x10000009, 0x1000000E, InputAction.ToggleAllegiancePanel); M(0x10000009, 0x1000000F, InputAction.ToggleFellowshipPanel); + M(0x10000009, 0x10000010, InputAction.ToggleSpellManagementPanel); M(0x10000009, 0x10000011, InputAction.ToggleSpellbookPanel); M(0x10000009, 0x10000012, InputAction.ToggleSpellComponentsPanel); + M(0x10000009, 0x10000013, InputAction.ToggleCharacterDetailPanel); M(0x10000009, 0x10000014, InputAction.ToggleAttributesPanel); M(0x10000009, 0x10000015, InputAction.ToggleSkillsPanel); M(0x10000009, 0x10000016, InputAction.ToggleWorldPanel); + M(0x10000009, 0x10000017, InputAction.ToggleMapPage); + M(0x10000009, 0x10000018, InputAction.ToggleHousePage); M(0x10000009, 0x1000001A, InputAction.ToggleOptionsPanel); M(0x10000009, 0x10000019, InputAction.ToggleInventoryPanel); + M(0x10000009, 0x1000001B, InputAction.ToggleGameplayOptionsPage); + M(0x10000009, 0x1000001C, InputAction.ToggleCharacterSettingsPage); + M(0x10000009, 0x1000001D, InputAction.ToggleConfigurationPage); + M(0x10000009, 0x1000001E, InputAction.ToggleCompass); + M(0x10000009, 0x1000001F, InputAction.ToggleKeyboardConfiguration); M(0x10000009, 0x10000114, InputAction.ToggleFloatingChatWindow1); M(0x10000009, 0x10000115, InputAction.ToggleFloatingChatWindow2); M(0x10000009, 0x10000116, InputAction.ToggleFloatingChatWindow3); @@ -206,19 +382,24 @@ public static class RetailActionIdentityTable M(0x10000009, 0x10000025, InputAction.UseSelected); M(0x10000009, 0x10000026, InputAction.LOGOUT); M(0x10000009, 0x1000002B, InputAction.SelectionExamine); - // 0x1000001F ("Show/Hide Keyboard Configuration") deliberately left - // unmapped: it is the retail action that opens THIS screen - // (research doc §4.3/lane A §7 — wired directly by - // KeyboardConfigController's mount, not through InputAction). - - // ── ChatCommands (ctx 0x1000000A) — 1/6. ─────────────────────── + M(0x10000009, 0x10000118, InputAction.ToggleFriendsPage); + M(0x10000009, 0x1000011A, InputAction.ToggleCharacterTitlesPage); + M(0x10000009, 0x10000127, InputAction.ToggleQuestDetailPanel); + M(0x10000009, 0x10000128, InputAction.ToggleQuestJournalPage); + M(0x10000009, 0x10000129, InputAction.ToggleJournalPageList); + M(0x10000009, 0x1000012E, InputAction.ToggleContractsPage); + // ── ChatCommands (ctx 0x1000000A) — 6/6. ─────────────────────── + M(0x1000000A, 0x10000020, InputAction.ChatMonarchReply); + M(0x1000000A, 0x10000021, InputAction.ChatPatronReply); + M(0x1000000A, 0x10000022, InputAction.ChatReply); M(0x1000000A, 0x10000023, InputAction.EnterChatMode); + M(0x1000000A, 0x10000028, InputAction.ChatStartCommand); + M(0x1000000A, 0x10000119, InputAction.ChatTellToSelected); // ── ToggleChatEntry (ctx 0x1000000D) — 1/1. ──────────────────── M(0x1000000D, 0x10000024, InputAction.ToggleChatEntry); - // ── QuickslotCommands (ctx 0x1000000C) — 24/28 (Quickslot - // 10/11/12/13 have no InputAction — pre-existing enum gap). ── + // ── QuickslotCommands (ctx 0x1000000C) — 28/28. ──────────────── M(0x1000000C, 0x10000042, InputAction.UseQuickSlot_1); M(0x1000000C, 0x10000043, InputAction.UseQuickSlot_2); M(0x1000000C, 0x10000044, InputAction.UseQuickSlot_3); @@ -228,6 +409,9 @@ public static class RetailActionIdentityTable M(0x1000000C, 0x10000048, InputAction.UseQuickSlot_7); M(0x1000000C, 0x10000049, InputAction.UseQuickSlot_8); M(0x1000000C, 0x1000004A, InputAction.UseQuickSlot_9); + M(0x1000000C, 0x1000004B, InputAction.UseQuickSlot_10); + M(0x1000000C, 0x1000004C, InputAction.UseQuickSlot_11); + M(0x1000000C, 0x1000004D, InputAction.UseQuickSlot_12); M(0x1000000C, 0x1000004E, InputAction.SelectQuickSlot_1); M(0x1000000C, 0x1000004F, InputAction.SelectQuickSlot_2); M(0x1000000C, 0x10000050, InputAction.SelectQuickSlot_3); @@ -238,16 +422,62 @@ public static class RetailActionIdentityTable M(0x1000000C, 0x10000055, InputAction.SelectQuickSlot_8); M(0x1000000C, 0x10000056, InputAction.SelectQuickSlot_9); M(0x1000000C, 0x1000010D, InputAction.CreateShortcut); - // 0x10000132 ("Quickslot 13") has no InputAction — same pre-existing - // UseQuickSlot_10..13 enum gap as the bare-numeral block above. Unmapped. + M(0x1000000C, 0x10000132, InputAction.UseQuickSlot_13); M(0x1000000C, 0x10000133, InputAction.UseQuickSlot_14); M(0x1000000C, 0x10000134, InputAction.UseQuickSlot_15); M(0x1000000C, 0x10000135, InputAction.UseQuickSlot_16); M(0x1000000C, 0x10000136, InputAction.UseQuickSlot_17); M(0x1000000C, 0x10000137, InputAction.UseQuickSlot_18); - // CharacterSettings (ctx 0x10000008) is intentionally EMPTY here — - // see class doc "Known gaps deliberately left unmapped". + // ── CharacterSettings (ctx 0x10000008) — 48/48. ──────────────── + M(0x10000008, 0x10000071, InputAction.ToggleCharacterOptionAutoRepeatAttack); + M(0x10000008, 0x10000072, InputAction.ToggleCharacterOptionIgnoreAllegianceRequests); + M(0x10000008, 0x10000073, InputAction.ToggleCharacterOptionIgnoreFellowshipRequests); + M(0x10000008, 0x10000074, InputAction.ToggleCharacterOptionIgnoreTradeRequests); + M(0x10000008, 0x10000076, InputAction.ToggleCharacterOptionPersistentAtDay); + M(0x10000008, 0x10000077, InputAction.ToggleCharacterOptionAllowGive); + M(0x10000008, 0x10000078, InputAction.ToggleCharacterOptionViewCombatTarget); + M(0x10000008, 0x10000079, InputAction.ToggleCharacterOptionShowTooltips); + M(0x10000008, 0x1000007A, InputAction.ToggleCharacterOptionUseDeception); + M(0x10000008, 0x1000007B, InputAction.ToggleCharacterOptionToggleRun); + M(0x10000008, 0x1000007C, InputAction.ToggleCharacterOptionStayInChatMode); + M(0x10000008, 0x1000007D, InputAction.ToggleCharacterOptionAdvancedCombatUi); + M(0x10000008, 0x1000007E, InputAction.ToggleCharacterOptionAutoTarget); + M(0x10000008, 0x1000007F, InputAction.ToggleCharacterOptionVividTargetingIndicator); + M(0x10000008, 0x10000080, InputAction.ToggleCharacterOptionFellowshipShareXp); + M(0x10000008, 0x10000081, InputAction.ToggleCharacterOptionAcceptLootPermits); + M(0x10000008, 0x10000082, InputAction.ToggleCharacterOptionFellowshipShareLoot); + M(0x10000008, 0x10000083, InputAction.ToggleCharacterOptionFellowshipAutoAcceptRequests); + M(0x10000008, 0x10000085, InputAction.ToggleCharacterOptionCoordinatesOnRadar); + M(0x10000008, 0x10000086, InputAction.ToggleCharacterOptionSpellDuration); + M(0x10000008, 0x10000087, InputAction.ToggleCharacterOptionDisableHouseRestrictionEffects); + M(0x10000008, 0x10000088, InputAction.ToggleCharacterOptionDragItemOnPlayerOpensSecureTrade); + M(0x10000008, 0x10000089, InputAction.ToggleCharacterOptionDisplayAllegianceLogonNotifications); + M(0x10000008, 0x1000008A, InputAction.ToggleCharacterOptionUseChargeAttack); + M(0x10000008, 0x1000008B, InputAction.ToggleCharacterOptionUseCraftSuccessDialog); + M(0x10000008, 0x1000008C, InputAction.ToggleCharacterOptionListenToAllegianceChat); + M(0x10000008, 0x1000008D, InputAction.ToggleCharacterOptionDisplayDateOfBirth); + M(0x10000008, 0x1000008E, InputAction.ToggleCharacterOptionDisplayAge); + M(0x10000008, 0x1000008F, InputAction.ToggleCharacterOptionDisplayChessRank); + M(0x10000008, 0x10000090, InputAction.ToggleCharacterOptionDisplayFishingSkill); + M(0x10000008, 0x10000091, InputAction.ToggleCharacterOptionDisplayNumberDeaths); + M(0x10000008, 0x10000092, InputAction.ToggleCharacterOptionDisplayTimeStamps); + M(0x10000008, 0x10000093, InputAction.ToggleCharacterOptionSalvageMultiple); + M(0x10000008, 0x1000010E, InputAction.ToggleCharacterOptionListenToGeneralChat); + M(0x10000008, 0x1000010F, InputAction.ToggleCharacterOptionListenToTradeChat); + M(0x10000008, 0x10000110, InputAction.ToggleCharacterOptionListenToLfgChat); + M(0x10000008, 0x10000112, InputAction.ToggleCharacterOptionListenToRoleplayChat); + M(0x10000008, 0x1000011B, InputAction.ToggleCharacterOptionDisplayNumberCharacterTitles); + M(0x10000008, 0x1000011D, InputAction.ToggleCharacterOptionMainPackPreferred); + M(0x10000008, 0x1000011E, InputAction.ToggleCharacterOptionLeadMissileTargets); + M(0x10000008, 0x1000011F, InputAction.ToggleCharacterOptionUseFastMissiles); + M(0x10000008, 0x10000120, InputAction.ToggleCharacterOptionFilterLanguage); + M(0x10000008, 0x10000123, InputAction.ToggleCharacterOptionConfirmVolatileRareUse); + M(0x10000008, 0x10000125, InputAction.ToggleCharacterOptionListenToSocietyChat); + M(0x10000008, 0x1000012A, InputAction.ToggleCharacterOptionShowHelm); + M(0x10000008, 0x1000012C, InputAction.ToggleCharacterOptionDisableDistanceFog); + M(0x10000008, 0x1000012F, InputAction.ToggleCharacterOptionShowCloak); + M(0x10000008, 0x1000013E, InputAction.ToggleCharacterOptionSideBySideVitals); return t; } diff --git a/src/AcDream.UI.Abstractions/Input/RetailScanCodeMap.cs b/src/AcDream.UI.Abstractions/Input/RetailScanCodeMap.cs index caa6188e..ce0ae50e 100644 --- a/src/AcDream.UI.Abstractions/Input/RetailScanCodeMap.cs +++ b/src/AcDream.UI.Abstractions/Input/RetailScanCodeMap.cs @@ -13,17 +13,17 @@ namespace AcDream.UI.Abstractions.Input; /// enum. /// /// -/// The scan-code table covers exactly the 84 distinct DIK codes that appear across -/// the DAT's 306 user-bindable ActionMap rows' default bindings (2026-08-11 probe — -/// see RetailActionMap.cs's class doc), cross-checked against +/// The scan-code table covers the 84 distinct DIK codes that appear across the +/// DAT's 306 user-bindable ActionMap rows' default bindings (2026-08-11 probe), +/// plus the remaining keyboard controls accepted by retail's plain-text keymap +/// interchange. The default set was cross-checked against /// tools/dump-keymap/Program.cs's own Dik(uint) transcription (itself /// verified against acclient_2013_pseudo_c.txt's /// ControlNameMapper::AddKeySemantic calls) and against /// 's existing chords, which already encode /// the same standard US-layout DirectInput scan codes by construction (both were -/// authored from the same retail-default.keymap.txt). Codes outside this set -/// (rare/debug/joystick bindings never seen with a non-empty default in the shipped -/// DAT) intentionally return null rather than guess. +/// authored from the same retail-default.keymap.txt). Unsupported joystick +/// controls intentionally return null rather than guess. /// /// public static class RetailScanCodeMap @@ -94,6 +94,7 @@ public static class RetailScanCodeMap 0x1A => Key.LeftBracket, 0x1B => Key.RightBracket, 0x1C => Key.Enter, + 0x1D => Key.ControlLeft, 0x1E => Key.A, 0x1F => Key.S, 0x20 => Key.D, @@ -120,7 +121,9 @@ public static class RetailScanCodeMap 0x35 => Key.Slash, 0x36 => Key.ShiftRight, 0x37 => Key.KeypadMultiply, + 0x38 => Key.AltLeft, 0x39 => Key.Space, + 0x3A => Key.CapsLock, 0x3B => Key.F1, 0x3C => Key.F2, 0x3D => Key.F3, @@ -148,9 +151,15 @@ public static class RetailScanCodeMap 0x53 => Key.KeypadDecimal, 0x57 => Key.F11, 0x58 => Key.F12, + 0x64 => Key.F13, + 0x65 => Key.F14, + 0x66 => Key.F15, 0x9C => Key.KeypadEnter, 0x9D => Key.ControlRight, 0xB5 => Key.KeypadDivide, + 0xB7 => Key.PrintScreen, + 0xB8 => Key.AltRight, + 0xC5 => Key.Pause, 0xC7 => Key.Home, 0xC8 => Key.Up, 0xC9 => Key.PageUp, @@ -161,7 +170,163 @@ public static class RetailScanCodeMap 0xD1 => Key.PageDown, 0xD2 => Key.Insert, 0xD3 => Key.Delete, + 0xDB => Key.SuperLeft, + 0xDC => Key.SuperRight, + 0xDD => Key.Menu, _ => null, }; } + + /// + /// Retail's plain-text .keymap control semantic to the same device / + /// scan-code pair consumed by . The legacy file + /// uses a few historical aliases (UPARROW, PGUP, + /// NUMPADSTAR, ...), so parsing accepts both those spellings and the + /// canonical DirectInput spellings emitted by . + /// + public static bool TryFromFileControl( + string control, + out uint scan, + out uint device) + { + scan = 0u; + device = 0u; + if (string.IsNullOrWhiteSpace(control)) + return false; + + string token = control.Trim().ToUpperInvariant(); + if (token.StartsWith("DIMOFS_BUTTON", StringComparison.Ordinal) + && int.TryParse(token["DIMOFS_BUTTON".Length..], out int button) + && button is >= 0 and <= 4) + { + scan = (uint)(0x0C + button); + device = 1u; + return true; + } + + if (!token.StartsWith("DIK_", StringComparison.Ordinal)) + return false; + token = token[4..]; + scan = token switch + { + "ESCAPE" => 0x01, + "1" => 0x02, "2" => 0x03, "3" => 0x04, "4" => 0x05, + "5" => 0x06, "6" => 0x07, "7" => 0x08, "8" => 0x09, + "9" => 0x0A, "0" => 0x0B, + "MINUS" => 0x0C, "EQUALS" => 0x0D, "BACK" => 0x0E, + "TAB" => 0x0F, + "Q" => 0x10, "W" => 0x11, "E" => 0x12, "R" => 0x13, + "T" => 0x14, "Y" => 0x15, "U" => 0x16, "I" => 0x17, + "O" => 0x18, "P" => 0x19, + "LBRACKET" => 0x1A, "RBRACKET" => 0x1B, "RETURN" => 0x1C, + "LCONTROL" => 0x1D, + "A" => 0x1E, "S" => 0x1F, "D" => 0x20, "F" => 0x21, + "G" => 0x22, "H" => 0x23, "J" => 0x24, "K" => 0x25, + "L" => 0x26, "SEMICOLON" => 0x27, "APOSTROPHE" => 0x28, + "GRAVE" => 0x29, "LSHIFT" => 0x2A, "BACKSLASH" => 0x2B, + "Z" => 0x2C, "X" => 0x2D, "C" => 0x2E, "V" => 0x2F, + "B" => 0x30, "N" => 0x31, "M" => 0x32, + "COMMA" => 0x33, "PERIOD" => 0x34, "SLASH" => 0x35, + "RSHIFT" => 0x36, "MULTIPLY" or "NUMPADSTAR" => 0x37, + "LMENU" or "LALT" => 0x38, "SPACE" => 0x39, "CAPITAL" => 0x3A, + "F1" => 0x3B, "F2" => 0x3C, "F3" => 0x3D, "F4" => 0x3E, + "F5" => 0x3F, "F6" => 0x40, "F7" => 0x41, "F8" => 0x42, + "F9" => 0x43, "F10" => 0x44, "NUMLOCK" => 0x45, + "SCROLL" => 0x46, "NUMPAD7" => 0x47, "NUMPAD8" => 0x48, + "NUMPAD9" => 0x49, "SUBTRACT" or "NUMPADMINUS" => 0x4A, + "NUMPAD4" => 0x4B, "NUMPAD5" => 0x4C, "NUMPAD6" => 0x4D, + "ADD" or "NUMPADPLUS" => 0x4E, "NUMPAD1" => 0x4F, + "NUMPAD2" => 0x50, "NUMPAD3" => 0x51, "NUMPAD0" => 0x52, + "DECIMAL" or "NUMPADPERIOD" => 0x53, + "F11" => 0x57, "F12" => 0x58, "F13" => 0x64, + "F14" => 0x65, "F15" => 0x66, "NUMPADENTER" => 0x9C, + "RCONTROL" => 0x9D, "DIVIDE" or "NUMPADSLASH" => 0xB5, + "SYSRQ" => 0xB7, "RMENU" or "RALT" => 0xB8, + "PAUSE" => 0xC5, "HOME" => 0xC7, + "UP" or "UPARROW" => 0xC8, "PRIOR" or "PGUP" => 0xC9, + "LEFT" => 0xCB, "RIGHT" or "RIGHTARROW" => 0xCD, + "END" => 0xCF, "DOWN" or "DOWNARROW" => 0xD0, + "NEXT" or "PGDN" => 0xD1, "INSERT" => 0xD2, + "DELETE" => 0xD3, "LWIN" => 0xDB, "RWIN" => 0xDC, + "APPS" => 0xDD, + _ => uint.MaxValue, + }; + return scan != uint.MaxValue; + } + + /// Converts an acdream chord to retail's plain-text control + /// semantic. Returns false for controls the 2013 DirectInput keymap cannot + /// represent (for example joystick axes). + public static bool TryToFileControl(KeyChord chord, out string control) + { + control = string.Empty; + if (chord.Device == 1) + { + int button = (int)chord.Key switch + { + -1001 => 0, + -1002 => 1, + -1003 => 2, + -1004 => 3, + -1005 => 4, + _ => -1, + }; + if (button < 0) return false; + control = $"DIMOFS_BUTTON{button}"; + return true; + } + if (chord.Device != 0) return false; + + for (uint scan = 1; scan <= 0xDD; scan++) + { + if (ToSilkKey(scan, 0) != chord.Key) continue; + control = scan switch + { + 0x37 => "DIK_NUMPADSTAR", + 0x4A => "DIK_NUMPADMINUS", + 0x4E => "DIK_NUMPADPLUS", + 0xB5 => "DIK_NUMPADSLASH", + 0xC8 => "DIK_UPARROW", + 0xC9 => "DIK_PGUP", + 0xCD => "DIK_RIGHTARROW", + 0xD0 => "DIK_DOWNARROW", + 0xD1 => "DIK_PGDN", + _ => FileToken(scan), + }; + return control.Length != 0; + } + return false; + } + + private static string FileToken(uint scan) => scan switch + { + 0x01 => "DIK_ESCAPE", + >= 0x02 and <= 0x0A => $"DIK_{scan - 1}", + 0x0B => "DIK_0", 0x0C => "DIK_MINUS", 0x0D => "DIK_EQUALS", + 0x0E => "DIK_BACK", 0x0F => "DIK_TAB", + >= 0x10 and <= 0x19 => $"DIK_{"QWERTYUIOP"[(int)(scan - 0x10)]}", + 0x1A => "DIK_LBRACKET", 0x1B => "DIK_RBRACKET", + 0x1C => "DIK_RETURN", 0x1D => "DIK_LCONTROL", + >= 0x1E and <= 0x26 => $"DIK_{"ASDFGHJKL"[(int)(scan - 0x1E)]}", + 0x27 => "DIK_SEMICOLON", 0x28 => "DIK_APOSTROPHE", + 0x29 => "DIK_GRAVE", 0x2A => "DIK_LSHIFT", 0x2B => "DIK_BACKSLASH", + >= 0x2C and <= 0x32 => $"DIK_{"ZXCVBNM"[(int)(scan - 0x2C)]}", + 0x33 => "DIK_COMMA", 0x34 => "DIK_PERIOD", 0x35 => "DIK_SLASH", + 0x36 => "DIK_RSHIFT", 0x38 => "DIK_LMENU", 0x39 => "DIK_SPACE", + 0x3A => "DIK_CAPITAL", + >= 0x3B and <= 0x44 => $"DIK_F{scan - 0x3A}", + 0x45 => "DIK_NUMLOCK", 0x46 => "DIK_SCROLL", + 0x47 => "DIK_NUMPAD7", 0x48 => "DIK_NUMPAD8", 0x49 => "DIK_NUMPAD9", + 0x4B => "DIK_NUMPAD4", 0x4C => "DIK_NUMPAD5", 0x4D => "DIK_NUMPAD6", + 0x4F => "DIK_NUMPAD1", 0x50 => "DIK_NUMPAD2", 0x51 => "DIK_NUMPAD3", + 0x52 => "DIK_NUMPAD0", 0x53 => "DIK_DECIMAL", + 0x57 => "DIK_F11", 0x58 => "DIK_F12", 0x64 => "DIK_F13", + 0x65 => "DIK_F14", 0x66 => "DIK_F15", 0x9C => "DIK_NUMPADENTER", + 0x9D => "DIK_RCONTROL", 0xB7 => "DIK_SYSRQ", 0xB8 => "DIK_RALT", + 0xC5 => "DIK_PAUSE", 0xC7 => "DIK_HOME", + 0xCB => "DIK_LEFT", 0xCF => "DIK_END", 0xD2 => "DIK_INSERT", + 0xD3 => "DIK_DELETE", 0xDB => "DIK_LWIN", 0xDC => "DIK_RWIN", + 0xDD => "DIK_APPS", + _ => string.Empty, + }; } diff --git a/src/AcDream.UI.Abstractions/Input/RetailUnmappedKeyBindings.cs b/src/AcDream.UI.Abstractions/Input/RetailUnmappedKeyBindings.cs index 37702866..242783c1 100644 --- a/src/AcDream.UI.Abstractions/Input/RetailUnmappedKeyBindings.cs +++ b/src/AcDream.UI.Abstractions/Input/RetailUnmappedKeyBindings.cs @@ -6,15 +6,13 @@ using System.Text.Json; namespace AcDream.UI.Abstractions.Input; /// -/// Campaign OP slice OP8: persisted bindings for DAT ActionMap rows that -/// RetailActionIdentityTable has no for — -/// mostly Emotes and CharacterSettings hotkeys (see that table's class doc for -/// the full accounting). These rows still render, bind, conflict-check, and -/// persist on the Configure Keyboard screen exactly like a mapped row; they -/// just have no live gameplay consumer to dispatch through yet, so they live in -/// their own small store rather than 's -/// -keyed schema. Sibling file next to -/// keybinds.json (D4 — no .keymap file interchange). +/// Forward-compatible persisted bindings for an ActionMap row introduced by a +/// future DAT revision. Campaign KB maps every one of the 306 rows in the +/// supported Sept-2013 EoR DAT, so this sibling file has no production entries +/// there; it only keeps an unknown future row visible and round-trippable +/// instead of crashing an older client. The compatibility sibling file stays +/// beside keybinds.json; installed-retail rows use the canonical +/// *.keymap profile instead. /// public sealed class RetailUnmappedKeyBindings { diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs index ee476db5..0d875761 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs @@ -59,6 +59,12 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback public string? LastOutgoingTellTarget => _commandTargets.LastOutgoingTellTarget; + public string? LastMonarchSender => + _commandTargets.LastMonarchSender; + + public string? LastPatronSender => + _commandTargets.LastPatronSender; + /// /// Optional callback exposing the live framerate. Wired by /// GameWindow at construction so the client-side diff --git a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs index 5494d766..5c6fbbec 100644 --- a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs @@ -61,6 +61,33 @@ public sealed class WorldLifecycleAutomationControllerTests } } + [Fact] + public void RetailScreenshotRequest_UsesFirstFreeScreenShotNumber() + { + string directory = NewDirectory(); + Directory.CreateDirectory(directory); + File.WriteAllBytes(Path.Combine(directory, "ScreenShot00000.png"), [0]); + var controller = new FrameScreenshotController( + (_, _) => [255, 255, 255, 255], + directory); + + try + { + Assert.True(controller.TryRequestRetailScreenshot( + out string path, + out string error), error); + Assert.Equal( + Path.Combine(directory, "ScreenShot00001.png"), + path); + Assert.True(controller.CapturePending(1, 1)); + Assert.True(File.Exists(path)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + [Fact] public void ScreenshotCapture_ReportsNoWorkAndFailedCapture() { diff --git a/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs b/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs index 5e8ac21c..bb2bd06f 100644 --- a/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/CameraPointerInputControllerTests.cs @@ -1,6 +1,7 @@ using System.Numerics; using AcDream.App.Input; using AcDream.App.Rendering; +using AcDream.Core.Rendering; using AcDream.UI.Abstractions.Input; using Silk.NET.Input; @@ -175,31 +176,63 @@ public sealed class CameraPointerInputControllerTests Assert.Equal(flyBefore * 1.2f, fixture.Owner.ActiveSensitivity, 5); } + [Fact] + public void ChaseMouseWheel_RetainsExtendedZoomOutRange() + { + var fixture = Create([new RawSurface()]); + var legacy = new ChaseCamera(); + var retail = new RetailChaseCamera(); + fixture.Mode.IsPlayerMode = true; + fixture.Chase.Legacy = legacy; + fixture.Chase.Retail = retail; + fixture.Camera.EnterChaseMode(legacy, retail); + + float before = CameraDiagnostics.UseRetailChaseCamera + ? retail.Distance + : legacy.Distance; + fixture.Owner.HandleScroll(InputAction.ScrollDown); + float afterOne = CameraDiagnostics.UseRetailChaseCamera + ? retail.Distance + : legacy.Distance; + for (int i = 0; i < 100; i++) + fixture.Owner.HandleScroll(InputAction.ScrollDown); + float afterMany = CameraDiagnostics.UseRetailChaseCamera + ? retail.Distance + : legacy.Distance; + + Assert.Equal(before + 0.8f, afterOne, 5); + Assert.Equal(40f, afterMany, 5); + } + private static Fixture Create(IReadOnlyList surfaces) { var camera = new CameraController(new OrbitCamera(), new FlyCamera()); var capture = new Capture(); var mouse = new Mouse(); var cursor = new Cursor(); + var mode = new LocalPlayerModeState(); + var chase = new ChaseCameraInputState(); var owner = new CameraPointerInputController( surfaces, cursor, new HostQuiescenceGate(), capture, - new LocalPlayerModeState(), + mode, camera, - new ChaseCameraInputState(), + chase, mouse, new PointerPositionState(), new Clock()); - return new Fixture(owner, camera, capture, cursor); + return new Fixture(owner, camera, capture, cursor, mode, chase); } private sealed record Fixture( CameraPointerInputController Owner, CameraController Camera, Capture Capture, - Cursor Cursor); + Cursor Cursor, + LocalPlayerModeState Mode, + ChaseCameraInputState Chase); private sealed class RawSurface : IRawPointerSurface { @@ -282,6 +315,7 @@ public sealed class CameraPointerInputControllerTests { public void Tick() { } public void HandleMovementInput(InputAction action, ActivationType activation) { } + public void AbortAutomaticAttack() { } public bool HandleInputAction(InputAction action, ActivationType activation) => false; } } diff --git a/tests/AcDream.App.Tests/Input/GameplayInputActionRouterTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputActionRouterTests.cs index 0d591f13..7868db80 100644 --- a/tests/AcDream.App.Tests/Input/GameplayInputActionRouterTests.cs +++ b/tests/AcDream.App.Tests/Input/GameplayInputActionRouterTests.cs @@ -1,6 +1,7 @@ using AcDream.App.Input; using AcDream.App.Rendering; using AcDream.Core.Combat; +using AcDream.Runtime; using AcDream.UI.Abstractions.Input; namespace AcDream.App.Tests.Input; @@ -11,9 +12,10 @@ public sealed class GameplayInputActionRouterTests [InlineData("pointer", "pointer")] [InlineData("combat", "pointer,combat")] [InlineData("retained", "pointer,combat,retained")] - [InlineData("selection", "pointer,combat,retained,selection")] - [InlineData("movement", "pointer,combat,retained,selection,movement")] - [InlineData("command", "pointer,combat,retained,selection,movement,command")] + [InlineData("character-option", "pointer,combat,retained,character-option")] + [InlineData("selection", "pointer,combat,retained,character-option,selection")] + [InlineData("movement", "pointer,combat,retained,character-option,selection,movement")] + [InlineData("command", "pointer,combat,retained,character-option,selection,movement,command")] public void Press_PreservesFrozenPriorityAndStopsAtConsumer( string consumeAt, string expectedCsv) @@ -66,7 +68,7 @@ public sealed class GameplayInputActionRouterTests ActivationType.DoubleClick); Assert.Equal( - ["pointer", "combat", "retained", "selection", "movement", "command"], + ["pointer", "combat", "retained", "character-option", "selection", "movement", "command"], harness.Targets.Calls); } @@ -81,7 +83,7 @@ public sealed class GameplayInputActionRouterTests ActivationType.Click); Assert.Equal( - ["pointer", "combat", "retained", "selection"], + ["pointer", "combat", "retained", "character-option", "selection"], harness.Targets.Calls); } @@ -100,6 +102,60 @@ public sealed class GameplayInputActionRouterTests harness.Actions.Scopes); } + [Theory] + [InlineData(InputAction.Ready, RuntimeMovementCommand.Ready)] + [InlineData(InputAction.Sitting, RuntimeMovementCommand.Sit)] + [InlineData(InputAction.Crouch, RuntimeMovementCommand.Crouch)] + [InlineData(InputAction.Sleeping, RuntimeMovementCommand.Sleep)] + public void RetailPostureKeys_MapToCanonicalRuntimeCommands( + InputAction action, + RuntimeMovementCommand expected) + { + Assert.Equal( + expected, + RuntimeGameplayInputPriorityTargets.ResolvePressedMovementCommand(action)); + } + + [Fact] + public void EscapeMovementRung_PreservesRetailPriority() + { + var charging = new AcDream.Runtime.Gameplay.JumpChargeSnapshot( + IsCharging: true, + Power: 0.5f); + var repeat = new RuntimeCombatAttackSnapshot( + 0, + AttackHeight.Medium, + 0f, + 0f, + false, + false, + 0f, + RepeatAttackInProgress: true); + + Assert.Equal( + RuntimeMovementCommand.FinishJump, + RuntimeGameplayInputPriorityTargets.ResolveEscapeMovementCommand( + InputAction.EscapeKey, + isStandingStill: false, + charging, + repeat)); + + Assert.Equal( + RuntimeMovementCommand.StopCompletely, + RuntimeGameplayInputPriorityTargets.ResolveEscapeMovementCommand( + InputAction.EscapeKey, + isStandingStill: false, + jumpCharge: default, + repeat)); + + Assert.Null( + RuntimeGameplayInputPriorityTargets.ResolveEscapeMovementCommand( + InputAction.EscapeKey, + isStandingStill: true, + jumpCharge: default, + repeat with { RepeatAttackInProgress = false })); + } + [Theory] [InlineData(0, "remove-actions")] [InlineData(1, "remove-combat,remove-actions")] @@ -277,6 +333,9 @@ public sealed class GameplayInputActionRouterTests public void SetCombatScope(InputScope? scope) => Scopes.Add(scope); + public void SetCameraAlternateScope(bool active) => + calls.Add($"camera-scope:{active}"); + public void Raise(InputAction action, ActivationType activation) => Callback?.Invoke(action, activation); } @@ -326,6 +385,9 @@ public sealed class GameplayInputActionRouterTests public bool HandleRetainedUiAction(InputAction action) => Record("retained"); + public bool HandleCharacterOptionAction(InputAction action) => + Record("character-option"); + public bool HandleSelectionAction(InputAction action) => Record("selection"); diff --git a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs index c0cfce5e..2ace75a5 100644 --- a/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/GameplayInputCommandControllerTests.cs @@ -23,6 +23,9 @@ public sealed class GameplayInputCommandControllerTests [InlineData(InputAction.ToggleFloatingChatWindow2, "chat-window-2")] [InlineData(InputAction.ToggleFloatingChatWindow3, "chat-window-3")] [InlineData(InputAction.ToggleFloatingChatWindow4, "chat-window-4")] + [InlineData(InputAction.ToggleChatEntry, "focus-chat")] + [InlineData(InputAction.EnterChatMode, "focus-chat")] + [InlineData(InputAction.LOGOUT, "logout")] public void RecognizedCommand_RoutesToTypedOwner( InputAction action, string expected) @@ -35,21 +38,12 @@ public sealed class GameplayInputCommandControllerTests Assert.Equal([expected], harness.Calls); } - // OP9: AcdreamToggleDebugPanel/ToggleChatEntry retired the - // IDevToolsGameplayCommands seam they used to forward to — both - // targets (the ImGui-era DebugPanel/ChatPanel) were already gone - // (Campaign V slice V11), so the seam's own body was an unconditional - // no-op. The action is still consumed (handled == true, matching the - // prior no-op's contract) but claims no typed-owner call. - [Theory] - [InlineData(InputAction.AcdreamToggleDebugPanel)] - [InlineData(InputAction.ToggleChatEntry)] - public void RetiredDevToolsCommand_IsConsumedWithoutClaimingATypedOwner( - InputAction action) + [Fact] + public void RetiredDebugPanelCommand_IsConsumedWithoutClaimingATypedOwner() { var harness = new Harness(); - bool handled = harness.Controller.Handle(action); + bool handled = harness.Controller.Handle(InputAction.AcdreamToggleDebugPanel); Assert.True(handled); Assert.Empty(harness.Calls); @@ -80,8 +74,9 @@ public sealed class GameplayInputCommandControllerTests } /// - /// Escape's priority chain: cancel a target mode, else leave player mode, - /// else close a window. + /// Escape's command-tier priority: cancel a target mode, otherwise toggle + /// retail's Gameplay Options page. It must never expose the developer/fly + /// camera or close the game window. /// /// /// The free-fly rung was REMOVED (2026-08-21, user direction: free-fly @@ -90,21 +85,15 @@ public sealed class GameplayInputCommandControllerTests /// rather than silently exiting a camera the player has no way to enter. /// [Theory] - [InlineData(true, true, true, "cancel-target")] - [InlineData(false, true, true, "exit-player")] - [InlineData(false, false, true, "exit-player")] - [InlineData(false, false, false, "close")] - public void Escape_PreservesTargetPlayerWindowPriority( + [InlineData(true, "cancel-target")] + [InlineData(false, "gameplay-options")] + public void Escape_PreservesRetailTargetThenGameplayOptionsPriority( bool targetMode, - bool flyMode, - bool playerMode, string expected) { var harness = new Harness { TargetMode = { IsActive = targetMode }, - Camera = { IsFly = flyMode }, - Player = { IsPlayer = playerMode }, }; bool handled = harness.Controller.Handle(InputAction.EscapeKey); @@ -121,19 +110,15 @@ public sealed class GameplayInputCommandControllerTests Diagnostics = new FakeDiagnostics(Calls); Player = new FakePlayerMode(Calls); TargetMode = new FakeTargetMode(Calls); - Camera = new FakeCamera(Calls); Combat = new FakeCombat(Calls); Runtime = new FakeRuntimeView(); - Window = new FakeWindow(Calls); Controller = new GameplayInputCommandController( Retained, Diagnostics, Player, TargetMode, - Camera, Runtime, - Combat, - Window); + Combat); } public List Calls { get; } = []; @@ -141,10 +126,8 @@ public sealed class GameplayInputCommandControllerTests public FakeDiagnostics Diagnostics { get; } public FakePlayerMode Player { get; } public FakeTargetMode TargetMode { get; } - public FakeCamera Camera { get; } public FakeCombat Combat { get; } public FakeRuntimeView Runtime { get; } - public FakeWindow Window { get; } public GameplayInputCommandController Controller { get; } } @@ -157,6 +140,12 @@ public sealed class GameplayInputCommandControllerTests calls.Add($"chat-window-{windowId}"); public void ToggleOptionsPanel() => calls.Add("options"); + + public void ToggleGameplayOptionsPage() => calls.Add("gameplay-options"); + + public void FocusChatEntry() => calls.Add("focus-chat"); + + public void LogOutCharacter() => calls.Add("logout"); } private sealed class FakeDiagnostics(List calls) @@ -180,11 +169,8 @@ public sealed class GameplayInputCommandControllerTests private sealed class FakePlayerMode(List calls) : IPlayerModeGameplayCommands { - public bool IsPlayer { get; set; } - public bool IsPlayerMode => IsPlayer; public void ToggleFlyOrChase() => calls.Add("fly-or-chase"); public void TogglePlayerMode() => calls.Add("player-mode"); - public void ExitPlayerMode() => calls.Add("exit-player"); } private sealed class FakeTargetMode(List calls) @@ -195,14 +181,6 @@ public sealed class GameplayInputCommandControllerTests public void CancelTargetMode() => calls.Add("cancel-target"); } - private sealed class FakeCamera(List calls) - : IGameplayCameraModeCommands - { - public bool IsFly { get; set; } - public bool IsFlyMode => IsFly; - public void ExitFlyMode() => calls.Add("exit-fly"); - } - private sealed class FakeCombat(List calls) : IRuntimeCombatCommands { public RuntimeCommandResult Execute( @@ -248,8 +226,4 @@ public sealed class GameplayInputCommandControllerTests throw new NotSupportedException(); } - private sealed class FakeWindow(List calls) : IGameplayWindowCommands - { - public void Close() => calls.Add("close"); - } } diff --git a/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs b/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs index 0389db2b..e71cb26f 100644 --- a/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs +++ b/tests/AcDream.App.Tests/Input/GameplayInputFrameControllerTests.cs @@ -127,6 +127,7 @@ public sealed class GameplayInputFrameControllerTests public void Tick() => _calls.Add("combat"); public void HandleMovementInput(InputAction action, ActivationType activation) => _calls.Add("combat-movement"); + public void AbortAutomaticAttack() => _calls.Add("combat-abort"); public bool HandleInputAction(InputAction action, ActivationType activation) { _calls.Add("combat-action"); diff --git a/tests/AcDream.App.Tests/Input/RetailEmoteMotionTableTests.cs b/tests/AcDream.App.Tests/Input/RetailEmoteMotionTableTests.cs new file mode 100644 index 00000000..3c04baf7 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/RetailEmoteMotionTableTests.cs @@ -0,0 +1,45 @@ +using AcDream.App.Input; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Tests.Input; + +public sealed class RetailEmoteMotionTableTests +{ + [Fact] + public void EveryRetailEmoteActionHasExactMotionConsumer() + { + InputAction[] emotes = RetailActionIdentityTable.Map + .Where(entry => entry.Key.InputMapId == 0x10000006u) + .OrderBy(entry => entry.Key.ActionId) + .Select(entry => entry.Value) + .ToArray(); + + Assert.Equal(87, RetailEmoteMotionTable.Count); + Assert.Equal(87, emotes.Length); + foreach (InputAction emote in emotes) + Assert.True(RetailEmoteMotionTable.TryGetMotion(emote, out _)); + } + + [Theory] + [InlineData(InputAction.EmoteAfkState, 0x43000118u)] + [InlineData(InputAction.Cheer, 0x1300004Cu)] + [InlineData(InputAction.Cry, 0x1300007Fu)] + [InlineData(InputAction.Laugh, 0x13000080u)] + [InlineData(InputAction.PointState, 0x430000F0u)] + [InlineData(InputAction.Wave, 0x13000087u)] + [InlineData(InputAction.EmoteYmca, 0x1200009Bu)] + public void RepresentativeActionsMatchNamedRetailGlobals( + InputAction action, + uint expectedMotion) + { + Assert.True(RetailEmoteMotionTable.TryGetMotion(action, out uint motion)); + Assert.Equal(expectedMotion, motion); + } + + [Theory] + [InlineData(InputAction.Ready)] + [InlineData(InputAction.ToggleOptionsPanel)] + [InlineData(InputAction.UseQuickSlot_1)] + public void NonEmoteActionsAreRejected(InputAction action) => + Assert.False(RetailEmoteMotionTable.TryGetMotion(action, out _)); +} diff --git a/tests/AcDream.App.Tests/Input/RetailKeymapFileTests.cs b/tests/AcDream.App.Tests/Input/RetailKeymapFileTests.cs new file mode 100644 index 00000000..39b29b88 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/RetailKeymapFileTests.cs @@ -0,0 +1,113 @@ +using AcDream.App.Input; +using AcDream.UI.Abstractions.Input; +using Silk.NET.Input; + +namespace AcDream.App.Tests.Input; + +public sealed class RetailKeymapFileTests +{ + [Fact] + public void Parse_CommittedRetailFile_ReproducesEveryUserBindableDefault() + { + string text = File.ReadAllText(Path.Combine( + FindRepoRoot(), "docs", "research", "named-retail", + "retail-default.keymap.txt")); + KeyBindings expected = KeyBindings.RetailDefaults(); + KeyBindings actual = RetailKeymapFile.Parse(text, expected); + + Assert.Equal( + expected.ForAction(InputAction.MovementForward).ToArray(), + actual.ForAction(InputAction.MovementForward).ToArray()); + Assert.Equal( + expected.ForAction(InputAction.MovementWalkMode).ToArray(), + actual.ForAction(InputAction.MovementWalkMode).ToArray()); + Assert.Equal( + expected.ForAction(InputAction.ToggleInventoryPanel).ToArray(), + actual.ForAction(InputAction.ToggleInventoryPanel).ToArray()); + Assert.Equal( + expected.ForAction(InputAction.CameraAlternateRotateLeft).ToArray(), + actual.ForAction(InputAction.CameraAlternateRotateLeft).ToArray()); + // The user's captured retail file omits slots 10-13; omission means + // unbound rather than "inherit a compiled default". + Assert.Empty(actual.ForAction(InputAction.UseQuickSlot_10)); + + // Host-only actions are outside retail's fourteen editable maps and survive. + Assert.Equal( + expected.ForAction(InputAction.AcdreamToggleAudioMute).ToArray(), + actual.ForAction(InputAction.AcdreamToggleAudioMute).ToArray()); + } + + [Fact] + public void WriteThenParse_RoundTripsAll306RetailActionIdentities() + { + var source = new KeyBindings(); + foreach (((uint inputMapId, uint actionId), InputAction action) in + RetailActionIdentityTable.Map) + { + source.Add(new Binding( + new KeyChord( + Key.SuperRight, + ModifierMask.Shift | ModifierMask.Ctrl | ModifierMask.Win), + action, + RetailActionIdentityTable.ActivationFor(inputMapId, actionId), + RetailActionIdentityTable.ScopeForInputMap(inputMapId))); + } + + string text = RetailKeymapFile.Write(source); + KeyBindings loaded = RetailKeymapFile.Parse(text, new KeyBindings()); + + Assert.Equal(306, loaded.All.Count); + foreach (Binding expected in source.All) + Assert.Contains(expected, loaded.All); + Assert.Contains("CharacterOptionCommands", text, StringComparison.Ordinal); + Assert.Contains("AutoRepeatAttacks", text, StringComparison.Ordinal); + Assert.Contains("AFKState", text, StringComparison.Ordinal); + Assert.Contains("DIK_RWIN", text, StringComparison.Ordinal); + Assert.Contains("EscapeKey [ \"\" [ 0 DIK_ESCAPE ] ]", text, StringComparison.Ordinal); + Assert.Contains("TargetedUsage", text, StringComparison.Ordinal); + } + + [Fact] + public void ProfileStore_SaveAsSelectsProfile_AndLoadReplacesRetailRows() + { + string root = Path.Combine(Path.GetTempPath(), "acdream-keymap-" + Guid.NewGuid().ToString("N")); + string config = Path.Combine(root, "config"); + string documents = Path.Combine(root, "documents", "Asheron's Call"); + string json = Path.Combine(config, "keybinds.json"); + try + { + var source = KeyBindings.RetailDefaults(); + var store = new RetailKeymapProfileStore(json, documents); + RetailKeymapSaveResult saved = store.Save("friends", source, overwrite: false); + + Assert.Equal(RetailKeymapSaveStatus.Saved, saved.Status); + Assert.Equal("friends.keymap", store.CurrentFileName); + Assert.Equal(new[] { "friends.keymap" }, store.ListFiles()); + Assert.True(store.TryLoad( + "friends.keymap", new KeyBindings(), out KeyBindings loaded, out string? error), + error); + Assert.Equal( + source.ForAction(InputAction.MovementForward).ToArray(), + loaded.ForAction(InputAction.MovementForward).ToArray()); + Assert.Equal( + RetailKeymapSaveStatus.Exists, + store.Save("friends", source, overwrite: false).Status); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + + private static string FindRepoRoot() + { + string? directory = AppContext.BaseDirectory; + while (directory is not null) + { + if (File.Exists(Path.Combine(directory, "AcDream.slnx"))) + return directory; + directory = Directory.GetParent(directory)?.FullName; + } + throw new DirectoryNotFoundException("Could not locate acdream.sln."); + } +} diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs index 0e28b3a9..0e30d2d6 100644 --- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -234,6 +234,25 @@ public sealed class SelectionInteractionControllerTests } } + [Fact] + public void Escape_ClearsCurrentSelectionBeforeTheOptionsFallback() + { + var h = new Harness(); + h.Selection.Select(Target, SelectionChangeSource.World); + + Assert.True(h.Controller.HandleInputAction(InputAction.EscapeKey)); + + Assert.Null(h.Selection.SelectedObjectId); + } + + [Fact] + public void Escape_WithNoTargetModeOrSelection_FallsThrough() + { + var h = new Harness(); + + Assert.False(h.Controller.HandleInputAction(InputAction.EscapeKey)); + } + [Fact] public void TargetModeClickPulsesBeforeItIsConsumedAndIncludesSelf() { @@ -323,6 +342,25 @@ public sealed class SelectionInteractionControllerTests Assert.Contains(h.Toasts, text => text.Contains("Target 70000001")); } + [Fact] + public void EveryRetailItemSelectionRowHasALiveControllerConsumer() + { + InputAction[] actions = RetailActionIdentityTable.Map + .Where(entry => entry.Key.InputMapId == 0x10000007u) + .OrderBy(entry => entry.Key.ActionId) + .Select(entry => entry.Value) + .ToArray(); + + Assert.Equal(26, actions.Length); + foreach (InputAction action in actions) + { + var harness = new Harness(); + Assert.True( + harness.Controller.HandleInputAction(action), + $"No selection consumer for {action}"); + } + } + [Fact] public void CloseUseSendsImmediatelyWithoutSpeculativeMovement() { diff --git a/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs b/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs index d56e046d..c731caa2 100644 --- a/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs +++ b/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs @@ -11,6 +11,7 @@ using AcDream.Core.Net; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; using AcDream.Core.Selection; +using AcDream.Core.Ui; using AcDream.Core.World; using DatReaderWriter.DBObjs; @@ -45,7 +46,10 @@ public sealed class WorldSelectionQueryTests public readonly LiveEntityRuntime Runtime; public readonly RetailSelectionScene Scene; public readonly WorldSelectionQuery Query; + public readonly ExternalContainerState ExternalContainers = new(); + public readonly HashSet Fellows = []; public PlayerInteractionPose? PlayerPose = new(0x0101_0001u, Vector3.Zero); + public CombatMode CurrentCombatMode = CombatMode.NonCombat; /// /// Stands in for EntityEffectPoseRegistry: the composed equipped-child @@ -74,7 +78,10 @@ public sealed class WorldSelectionQueryTests _ => (new Vector3(1f, 0f, 0f), 2f), localEntityId => ChildRoots.TryGetValue(localEntityId, out Matrix4x4 root) ? root - : null); + : null, + ExternalContainers.HasCorpseBeenOpened, + () => CurrentCombatMode, + Fellows.Contains); Add(Player, Vector3.Zero, ItemType.Creature, SelectedObjectHealthPolicy.BfPlayer); } @@ -89,7 +96,8 @@ public sealed class WorldSelectionQueryTests ushort instance = 1, float scale = 1f, Quaternion? rotation = null, - float? useRadius = null) + float? useRadius = null, + byte? radarBehavior = null) { WorldSession.EntitySpawn spawn = Spawn(guid, instance) with { @@ -109,6 +117,7 @@ public sealed class WorldSelectionQueryTests Name = $"Object {guid:X8}", Type = type, PublicWeenieBitfield = publicFlags, + RadarBehavior = radarBehavior, }); return entity; } @@ -301,6 +310,187 @@ public sealed class WorldSelectionQueryTests Assert.Equal(64f, closest?.DistanceSquared); } + [Fact] + public void RetailItemSelectionUsesRadarAndSpecialObjectRules() + { + var h = new Harness(); + const uint radarItem = 0x7000_0020u; + const uint ordinaryItem = 0x7000_0021u; + const uint portal = 0x7000_0022u; + h.Add( + radarItem, + new Vector3(1f, 0f, 0f), + ItemType.Misc, + radarBehavior: (byte)RadarBehavior.ShowAlways); + h.Add(ordinaryItem, new Vector3(2f, 0f, 0f), ItemType.Misc); + h.Add( + portal, + new Vector3(3f, 0f, 0f), + ItemType.Misc, + publicFlags: (uint)PublicWeenieFlags.Portal, + radarBehavior: (byte)RadarBehavior.ShowAlways); + + Assert.Equal( + ordinaryItem, + h.Query.FindSelectionTarget( + RetailSelectionKind.Item, + RetailSelectionDirection.Closest, + anchor: null)); + + // A radar-authored item is not in the Item cycle unless it carries + // one of retail's three explicit exceptions (lifestone/portal/ + // bindstone). + h.Objects.Get(ordinaryItem)!.ContainerId = Player; + Assert.Equal( + portal, + h.Query.FindSelectionTarget( + RetailSelectionKind.Item, + RetailSelectionDirection.Closest, + anchor: null)); + } + + [Fact] + public void RetailCompassSelectionChangesPredicateInPhysicalCombat() + { + var h = new Harness(); + const uint peaceful = 0x7000_0030u; + const uint fellow = 0x7000_0031u; + const uint vendor = 0x7000_0032u; + const uint environment = 0x7000_0033u; + const uint hostile = 0x7000_0034u; + h.Add( + peaceful, + new Vector3(1f, 0f, 0f), + ItemType.Misc, + radarBehavior: (byte)RadarBehavior.ShowAlways); + h.Add( + fellow, + new Vector3(2f, 0f, 0f), + ItemType.Creature, + publicFlags: (uint)PublicWeenieFlags.Attackable, + radarBehavior: (byte)RadarBehavior.ShowAlways); + h.Fellows.Add(fellow); + h.Add( + vendor, + new Vector3(3f, 0f, 0f), + ItemType.Creature, + publicFlags: (uint)(PublicWeenieFlags.Attackable | PublicWeenieFlags.Vendor), + radarBehavior: (byte)RadarBehavior.ShowAlways); + h.Add( + environment, + new Vector3(4f, 0f, 0f), + ItemType.Creature, + publicFlags: (uint)PublicWeenieFlags.Attackable, + radarBehavior: (byte)RadarBehavior.ShowAlways); + Assert.True(h.Runtime.TryApplyState( + new SetState.Parsed( + environment, + (uint)(PhysicsStateFlags.ReportCollisions + | PhysicsStateFlags.ReportAsEnvironment), + InstanceSequence: 1, + StateSequence: 2), + out _)); + h.Add( + hostile, + new Vector3(5f, 0f, 0f), + ItemType.Creature, + publicFlags: (uint)PublicWeenieFlags.Attackable, + radarBehavior: (byte)RadarBehavior.ShowAlways); + + Assert.Equal( + peaceful, + h.Query.FindSelectionTarget( + RetailSelectionKind.CompassItem, + RetailSelectionDirection.Closest, + anchor: null)); + + h.CurrentCombatMode = CombatMode.Melee; + Assert.Equal( + hostile, + h.Query.FindSelectionTarget( + RetailSelectionKind.CompassItem, + RetailSelectionDirection.Closest, + anchor: null)); + + // Retail's special combat-only compass restriction does not apply + // in magic mode. + h.CurrentCombatMode = CombatMode.Magic; + Assert.Equal( + peaceful, + h.Query.FindSelectionTarget( + RetailSelectionKind.CompassItem, + RetailSelectionDirection.Closest, + anchor: null)); + } + + [Fact] + public void RetailMonsterSelectionUsesObjectIsAttackableAndRejectsFellowsAndVendors() + { + var h = new Harness(); + const uint fellow = 0x7000_0040u; + const uint vendor = 0x7000_0041u; + const uint hostile = 0x7000_0042u; + h.Add( + fellow, + new Vector3(1f, 0f, 0f), + ItemType.Creature, + publicFlags: (uint)PublicWeenieFlags.Attackable, + radarBehavior: (byte)RadarBehavior.ShowAlways); + h.Fellows.Add(fellow); + h.Add( + vendor, + new Vector3(2f, 0f, 0f), + ItemType.Creature, + publicFlags: (uint)(PublicWeenieFlags.Attackable | PublicWeenieFlags.Vendor), + radarBehavior: (byte)RadarBehavior.ShowAlways); + h.Add( + hostile, + new Vector3(3f, 0f, 0f), + ItemType.Creature, + publicFlags: (uint)PublicWeenieFlags.Attackable, + radarBehavior: (byte)RadarBehavior.ShowAlways); + + Assert.Equal( + hostile, + h.Query.FindSelectionTarget( + RetailSelectionKind.Monster, + RetailSelectionDirection.Closest, + anchor: null)); + } + + [Fact] + public void RetailUnopenedCorpseSelectionRemembersOpenUntilDelete() + { + var h = new Harness(); + const uint corpse = 0x7000_0050u; + h.Add( + corpse, + new Vector3(1f, 0f, 0f), + ItemType.Container, + publicFlags: (uint)PublicWeenieFlags.Corpse); + + Assert.Equal( + corpse, + h.Query.FindSelectionTarget( + RetailSelectionKind.UnopenedCorpse, + RetailSelectionDirection.Closest, + anchor: null)); + + Assert.True(h.ExternalContainers.RequestOpen(corpse, isCorpse: true)); + Assert.Null(h.Query.FindSelectionTarget( + RetailSelectionKind.UnopenedCorpse, + RetailSelectionDirection.Closest, + anchor: null)); + + Assert.True(h.ExternalContainers.SetCorpseDeleted(corpse)); + Assert.Equal( + corpse, + h.Query.FindSelectionTarget( + RetailSelectionKind.UnopenedCorpse, + RetailSelectionDirection.Closest, + anchor: null)); + } + /// /// #298 follow-up: retail ClientCombatSystem::UpdateTargetTracking /// @ 0x0056A950 (pc:375691-375696) gates CameraSet::TrackTarget diff --git a/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs b/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs index 7228a136..41f47c47 100644 --- a/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PaperdollFramePresenterTests.cs @@ -9,17 +9,18 @@ namespace AcDream.App.Tests.Rendering; public sealed class PaperdollFramePresenterTests { [Fact] - public void HiddenView_DoesNotBuildOrRender() + public void HiddenView_BuildsAndPrewarmsWithoutRendering() { var renderer = new RecordingRenderer(); var view = new RecordingView { Visible = false }; - var factory = new RecordingFactory(); + var factory = new RecordingFactory { Doll = CreateDoll() }; var presenter = new PaperdollFramePresenter(renderer, view, factory); presenter.Render(); - Assert.True(presenter.IsDirty); - Assert.Equal(0, factory.BuildCount); + Assert.False(presenter.IsDirty); + Assert.Equal(1, factory.BuildCount); + Assert.Equal(1, renderer.PrepareCount); Assert.Equal(0, renderer.RenderCount); Assert.Empty(view.TextureHandles); } @@ -255,9 +256,12 @@ public sealed class PaperdollFramePresenterTests public List Dolls { get; } = []; public List<(int Width, int Height)> RenderSizes { get; } = []; public int RenderCount => RenderSizes.Count; + public int PrepareCount { get; private set; } public void SetDoll(WorldEntity? doll) => Dolls.Add(doll); + public void Prepare() => PrepareCount++; + public uint Render(int width, int height) { RenderSizes.Add((width, height)); diff --git a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs index 653a5182..84283765 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs @@ -209,6 +209,25 @@ public class RetailChaseCameraTests Assert.Equal(Vector3.Normalize(pivot - eye), forward); } + [Fact] + public void MapMode_TargetDirectionTransformsViewerOffsetIntoOverheadPose() + { + var pivot = new Vector3(10f, 20f, 1.5f); + float distance = MathF.Sqrt(450f * 450f + 0.75f * 0.75f); + float pitch = MathF.Atan2(0.75f, 450f); + + var (eye, forward) = RetailChaseCamera.ComputeTargetDirectionPose( + pivot, + Vector3.UnitX, + distance, + pitch, + new Vector3(0f, 0.5f, -1.8f)); + + Assert.True(eye.Z > 430f, $"expected retail overhead eye, got Z={eye.Z}"); + Assert.InRange(Vector2.Distance(new Vector2(eye.X, eye.Y), new Vector2(pivot.X, pivot.Y)), 119f, 122f); + Assert.True(forward.Z < -0.95f, $"expected steep downward view, got {forward}"); + } + [Fact] public void Basis_HorizontalHeading_IsOrthonormalAndRightHanded() { @@ -528,6 +547,41 @@ public class RetailChaseCameraTests Assert.Equal(RetailChaseCamera.DistanceMax, cam.Distance); } + [Fact] + public void SetRetailFirstPersonView_PlacesEyeAheadAndLooksForward() + { + var cam = new RetailChaseCamera(); + cam.SetRetailFirstPersonView(); + + cam.Update( + playerPosition: Vector3.Zero, + playerYaw: 0f, + playerVelocity: Vector3.Zero, + isOnGround: true, + contactPlaneNormal: Vector3.UnitZ, + dt: 1f / 60f); + + Assert.True(cam.IsInHead); + Assert.Equal(new Vector3(0.18f, 0f, 1.5f), cam.Position); + Assert.Equal(1f, cam.PlayerTranslucency, 5); + var (_, forward) = RetailChaseCamera.ComputeInHeadPose( + new Vector3(0f, 0f, 1.5f), + Vector3.UnitX); + Assert.Equal(Vector3.UnitX, forward); + } + + [Fact] + public void AdjustingZoomExitsRetailFirstPersonView() + { + var cam = new RetailChaseCamera(); + cam.SetRetailFirstPersonView(); + + cam.AdjustDistance(1f); + + Assert.False(cam.IsInHead); + Assert.Equal(RetailChaseCamera.DistanceMin + 1f, cam.Distance); + } + [Fact] public void AdjustPitch_ClampsToRange() { diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index d35b5bf3..087a29a2 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -338,6 +338,11 @@ public sealed class CurrentGameRuntimeAdapterTests Assert.True(graphicalInput.HandleInputAction( InputAction.CombatLowAttack, ActivationType.Press)); + Assert.True(graphical.Actions.View.Snapshot.CombatAttack.RequestInProgress); + Assert.True(graphicalInput.HandleInputAction( + InputAction.CombatLowAttack, + ActivationType.Hold)); + Assert.True(graphical.Actions.View.Snapshot.CombatAttack.RequestInProgress); Assert.True(graphicalInput.HandleInputAction( InputAction.CombatLowAttack, ActivationType.Release)); @@ -1280,6 +1285,10 @@ public sealed class CurrentGameRuntimeAdapterTests { } + public void AbortAutomaticAttack() + { + } + public bool HandleInputAction( InputAction action, ActivationType activation) => false; diff --git a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs index f176db45..276e2610 100644 --- a/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LocalPlayerTeleportControllerTests.cs @@ -1400,6 +1400,7 @@ public sealed class LocalPlayerTeleportControllerTests public bool IsRecenterPending => RecenterPending; public bool RecenterPending; public bool ResetResult = true; + public int ResetCalls; public bool LastResetWasSessionEnding; public readonly List<(int X, int Y, bool Sealed)> Recenters = new(); public readonly List<(long Generation, uint Cell, int Radius)> @@ -1414,6 +1415,7 @@ public sealed class LocalPlayerTeleportControllerTests public bool ResetRecenter(bool sessionEnding) { + ResetCalls++; LastResetWasSessionEnding = sessionEnding; return ResetResult; } @@ -1954,6 +1956,7 @@ public sealed class LocalPlayerTeleportControllerTests harness.Controller.ResetGenerationPresentation(); harness.Controller.Tick(0.016f); Assert.Equal(1, harness.Logout.CompleteCalls); + Assert.Equal(1, harness.Streaming.ResetCalls); Assert.Equal(RuntimeLogoutStage.None, harness.Transit.LogoutStage); // The transaction's world reset retired the presentation. Assert.Contains("presentation-reset", order); @@ -1966,6 +1969,34 @@ public sealed class LocalPlayerTeleportControllerTests Assert.Equal(1, harness.Logout.CompleteCalls); } + [Fact] + public void LogoutConfirmation_WaitsForOldStreamingWindowBeforeFreshGeneration() + { + var harness = new Harness(worldReady: true); + Assert.True(harness.Controller.TryRequestLogout()); + harness.Logout.IsCharacterLogOffConfirmed = true; + harness.Streaming.ResetResult = false; + harness.Logout.OnComplete = () => + harness.Controller.ResetGenerationPresentation(); + + harness.Controller.Tick(0.016f); + + Assert.Equal(RuntimeLogoutStage.Confirmed, harness.Transit.LogoutStage); + Assert.Equal(0, harness.Logout.CompleteCalls); + Assert.Equal(1, harness.Streaming.ResetCalls); + + // StreamingController.Tick advances the retained retirement between + // these controller ticks. Once converged, the logout transaction may + // expose the next generation. Its reset callback consumes the same + // retirement instead of beginning a second one. + harness.Streaming.ResetResult = true; + harness.Controller.Tick(0.016f); + + Assert.Equal(RuntimeLogoutStage.None, harness.Transit.LogoutStage); + Assert.Equal(1, harness.Logout.CompleteCalls); + Assert.Equal(2, harness.Streaming.ResetCalls); + } + [Fact] public void LogoutConfirmationBeforeHoldEnd_SkipsTheWormholeEntirely() { diff --git a/tests/AcDream.App.Tests/UI/AutoWieldGenerationTests.cs b/tests/AcDream.App.Tests/UI/AutoWieldGenerationTests.cs index 4233a3bb..8d4ab29e 100644 --- a/tests/AcDream.App.Tests/UI/AutoWieldGenerationTests.cs +++ b/tests/AcDream.App.Tests/UI/AutoWieldGenerationTests.cs @@ -35,8 +35,7 @@ public sealed class AutoWieldGenerationTests objects, () => Player, sendWield: null, - sendPutItemInContainer: (_, _, _) => { }, - toast: null); + sendPutItemInContainer: (_, _, _) => { }); Assert.True(controller.TryWield(requested)); Assert.True(controller.IsBusy); diff --git a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs index 7cf9e128..8b5534cb 100644 --- a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs +++ b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs @@ -182,6 +182,38 @@ public class DragDropSpineTests Assert.Equal((0x99u, 32, 32), root.DragGhostForTest); } + [Fact] + public void RootOwnedDrag_survivesProceduralSourceCellReplacement_untilRelease() + { + var (root, list, cell) = RootWithBoundSlot(0x5001u); + object? released = null; + root.DragReleasedOutsideUi += (payload, _, _) => released = payload; + + root.OnMouseDown(UiMouseButton.Left, 10, 10); + root.OnMouseMove(20, 10); // BeginDrag → root owns ghost + object payload = Assert.IsType(root.DragPayload); + + // InventoryController.Populate/ExternalContainerController.Populate use + // UiItemList.Flush when an unrelated authoritative item update arrives. + // The old source cell is replaced while the physical button is still down. + list.Flush(); + + Assert.Null(cell.Parent); + Assert.Same(cell, root.DragSource); + Assert.Same(payload, root.DragPayload); + Assert.Equal((0x99u, 32, 32), root.DragGhostForTest); + Assert.Same(root, root.Captured); // retail drag element owns capture + + root.OnMouseMove(600, 500); + root.OnMouseUp(UiMouseButton.Left, 600, 500); + + Assert.Same(payload, released); + Assert.Null(root.DragSource); + Assert.Null(root.DragPayload); + Assert.Null(root.DragGhostForTest); + Assert.Null(root.Captured); + } + [Fact] public void FinishDrag_overNothing_deliversNoDrop_butLiftStands() { diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 2276902b..f03c7123 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -23,6 +23,7 @@ public sealed class ItemInteractionControllerTests public readonly List<(uint Item, uint Mask)> Wields = new(); public readonly List<(uint Item, uint Container, int Placement)> Puts = new(); public readonly List<(uint Item, uint Container, uint Placement, uint Amount)> SplitPuts = new(); + public readonly List<(uint Source, uint Target, uint Amount)> Merges = new(); public readonly List ExternalRequests = new(); public readonly List<(uint Item, uint Container, int Placement)> BackpackPlacements = new(); public readonly List Drops = new(); @@ -130,7 +131,9 @@ public sealed class ItemInteractionControllerTests Sells.Add((vendorGuid, items)); return true; }, - interfaceText: (text, type) => InterfaceTexts.Add((text, type))); + interfaceText: (text, type) => InterfaceTexts.Add((text, type)), + sendStackableMerge: (source, target, amount) => + Merges.Add((source, target, amount))); } public ItemInteractionController Controller { get; } @@ -589,7 +592,7 @@ public sealed class ItemInteractionControllerTests } [Fact] - public void EquippableItemWithFreeSlot_sendsGetAndWieldAndMovesOptimistically() + public void EquippableItemWithFreeSlot_sendsGetAndWieldAndWaitsForServer() { var h = new Harness(); h.AddContained(0x50000A05u, item => @@ -602,12 +605,12 @@ public sealed class ItemInteractionControllerTests Assert.Equal(new[] { (0x50000A05u, (uint)EquipMask.HeadWear) }, h.Wields); var equipped = h.Objects.Get(0x50000A05u)!; - Assert.Equal(Player, equipped.ContainerId); - Assert.Equal(EquipMask.HeadWear, equipped.CurrentlyEquippedLocation); + Assert.Equal(Pack, equipped.ContainerId); + Assert.Equal(EquipMask.None, equipped.CurrentlyEquippedLocation); } [Fact] - public void EquippableMultiSlotItemWithFreeSlots_sendsFullCoverageMaskAndMovesOptimistically() + public void EquippableMultiSlotItemWithFreeSlots_sendsFullCoverageMaskAndWaitsForServer() { var h = new Harness(); const EquipMask coatMask = @@ -624,8 +627,8 @@ public sealed class ItemInteractionControllerTests Assert.Equal(new[] { (0x50000A15u, (uint)coatMask) }, h.Wields); var equipped = h.Objects.Get(0x50000A15u)!; - Assert.Equal(Player, equipped.ContainerId); - Assert.Equal(coatMask, equipped.CurrentlyEquippedLocation); + Assert.Equal(Pack, equipped.ContainerId); + Assert.Equal(EquipMask.None, equipped.CurrentlyEquippedLocation); } [Fact] @@ -654,7 +657,7 @@ public sealed class ItemInteractionControllerTests Assert.True(h.Controller.ActivateItem(0x50000A16u)); Assert.Equal(new[] { (0x50000A16u, (uint)coatMask) }, h.Wields); - Assert.Equal(coatMask, h.Objects.Get(0x50000A16u)!.CurrentlyEquippedLocation); + Assert.Equal(EquipMask.None, h.Objects.Get(0x50000A16u)!.CurrentlyEquippedLocation); } [Fact] @@ -692,12 +695,13 @@ public sealed class ItemInteractionControllerTests } [Fact] - public void EquippableItemWithNoFreeSlot_sendsNothing() + public void EquippableItemWithNoFreeSlot_movesBlockerThenWieldsAfterServerConfirm() { var h = new Harness(); h.Objects.AddOrUpdate(new ClientObject { ObjectId = 0x50000AF0u, + Name = "Old Shield", Type = ItemType.Armor, CurrentlyEquippedLocation = EquipMask.Shield, }); @@ -712,9 +716,17 @@ public sealed class ItemInteractionControllerTests bool activated = h.Controller.ActivateItem(0x50000A06u); + Assert.True(activated); Assert.Empty(h.Wields); - Assert.False(activated); + Assert.Equal(new[] { (0x50000AF0u, Player, 0) }, h.Puts); + Assert.Equal(new[] { "Moving Old Shield to your backpack" }, h.SystemMessages); Assert.Equal(Pack, h.Objects.Get(0x50000A06u)!.ContainerId); + + Assert.True(h.Objects.ApplyConfirmedServerMove(0x50000AF0u, Player, 0u, 0)); + + Assert.Equal( + new[] { (0x50000A06u, (uint)EquipMask.Shield) }, + h.Wields); } [Theory] @@ -752,14 +764,18 @@ public sealed class ItemInteractionControllerTests Assert.Equal(Pack, h.Objects.Get(bow)!.ContainerId); // Authoritative 0x0022: only now does retail retry AutoWield. - h.Objects.MoveItem(sword, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(sword, Player, 0u, 0)); Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields); Assert.Equal(EquipMask.None, h.Objects.Get(sword)!.CurrentlyEquippedLocation); + Assert.Equal(EquipMask.None, + h.Objects.Get(bow)!.CurrentlyEquippedLocation); + Assert.Equal(Pack, h.Objects.Get(bow)!.ContainerId); + Assert.True(h.Objects.ApplyConfirmedServerWield( + bow, Player, EquipMask.MissileWeapon)); Assert.Equal(EquipMask.MissileWeapon, h.Objects.Get(bow)!.CurrentlyEquippedLocation); - Assert.Equal(Player, h.Objects.Get(bow)!.ContainerId); } [Theory] @@ -797,7 +813,7 @@ public sealed class ItemInteractionControllerTests // wand away. The transaction retains the initial active-combat intent // rather than consulting this intermediate state on its second pass. h.Combat.SetCombatMode(CombatMode.Melee); - h.Objects.MoveItem(wand, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(wand, Player, 0u, 0)); Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields); Assert.Empty(h.CombatModeRequests); @@ -860,7 +876,7 @@ public sealed class ItemInteractionControllerTests // replacement wield. h.Combat.SetCombatMode(CombatMode.Melee); h.Combat.SetCombatMode(CombatMode.NonCombat); - h.Objects.MoveItem(bow, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(bow, Player, 0u, 0)); Assert.Equal(new[] { (wand, (uint)EquipMask.Held) }, h.Wields); Assert.True(h.Objects.ApplyConfirmedServerWield( @@ -899,7 +915,7 @@ public sealed class ItemInteractionControllerTests Assert.True(h.Controller.ActivateItem(wand)); h.Combat.SetCombatMode(CombatMode.NonCombat); - h.Objects.MoveItem(bow, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(bow, Player, 0u, 0)); Assert.True(h.Objects.ApplyConfirmedServerWield( wand, Player, @@ -935,7 +951,7 @@ public sealed class ItemInteractionControllerTests }); Assert.True(h.Controller.ActivateItem(bow)); - h.Objects.MoveItem(wand, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(wand, Player, 0u, 0)); Assert.True(h.Objects.ApplyConfirmedServerWield( bow, Player, EquipMask.MissileWeapon)); @@ -1027,7 +1043,7 @@ public sealed class ItemInteractionControllerTests }); Assert.True(h.Controller.ActivateItem(bow)); - h.Objects.MoveItem(sword, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(sword, Player, 0u, 0)); Assert.Single(h.Wields); // Bow is optimistic but ACE has not sent WieldObject yet. Retail's @@ -1121,13 +1137,13 @@ public sealed class ItemInteractionControllerTests Assert.True(h.Controller.ActivateItem(bow)); Assert.Equal(new[] { (sword, Player, 0) }, h.Puts); - h.Objects.MoveItem(sword, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(sword, Player, 0u, 0)); Assert.Equal( new[] { (sword, Player, 0), (shield, Player, 0) }, h.Puts); Assert.Empty(h.Wields); - h.Objects.MoveItem(shield, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(shield, Player, 0u, 0)); Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields); } @@ -1164,12 +1180,12 @@ public sealed class ItemInteractionControllerTests }); Assert.True(h.Controller.ActivateItem(bow)); - h.Objects.MoveItem(sword, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(sword, Player, 0u, 0)); Assert.Equal( new[] { (sword, Player, 0), (arrows, Player, 0) }, h.Puts); - h.Objects.MoveItem(arrows, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(arrows, Player, 0u, 0)); Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields); } @@ -1213,7 +1229,7 @@ public sealed class ItemInteractionControllerTests Assert.Empty(h.Wields); Assert.Equal(new[] { "Moving Shortbow to your backpack" }, h.SystemMessages); - h.Objects.MoveItem(bow, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(bow, Player, 0u, 0)); Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, h.Wields); Assert.Equal(EquipMask.MissileAmmo, @@ -1247,7 +1263,7 @@ public sealed class ItemInteractionControllerTests Assert.Empty(h.Wields); Assert.Equal(new[] { "Moving Wand to your backpack" }, h.SystemMessages); - h.Objects.MoveItem(wand, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(wand, Player, 0u, 0)); Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, h.Wields); } @@ -1292,14 +1308,14 @@ public sealed class ItemInteractionControllerTests crossbow, EquipMask.MissileWeapon)); Assert.Equal(new[] { (bow, Player, 0) }, h.Puts); - h.Objects.MoveItem(bow, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(bow, Player, 0u, 0)); Assert.Equal( new[] { (bow, Player, 0), (arrows, Player, 0) }, h.Puts); Assert.Empty(h.Wields); - h.Objects.MoveItem(arrows, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(arrows, Player, 0u, 0)); Assert.Equal( new[] { (crossbow, (uint)EquipMask.MissileWeapon) }, @@ -1333,7 +1349,11 @@ public sealed class ItemInteractionControllerTests new[] { (ring, (uint)EquipMask.FingerWearRight) }, h.Wields); Assert.Equal( - EquipMask.FingerWearRight, + EquipMask.FingerWearLeft, + h.Objects.Get(ring)!.CurrentlyEquippedLocation); + Assert.True(h.Objects.ApplyConfirmedServerWield( + ring, Player, EquipMask.FingerWearRight)); + Assert.Equal(EquipMask.FingerWearRight, h.Objects.Get(ring)!.CurrentlyEquippedLocation); } @@ -1366,14 +1386,61 @@ public sealed class ItemInteractionControllerTests Assert.Empty(h.Wields); Assert.Equal(new[] { "Moving Right Ring to your backpack" }, h.SystemMessages); - h.Objects.MoveItem(rightRing, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(rightRing, Player, 0u, 0)); Assert.Equal( new[] { (leftRing, (uint)EquipMask.FingerWearRight) }, h.Wields); Assert.Equal( - EquipMask.FingerWearRight, + EquipMask.FingerWearLeft, h.Objects.Get(leftRing)!.CurrentlyEquippedLocation); + Assert.True(h.Objects.ApplyConfirmedServerWield( + leftRing, Player, EquipMask.FingerWearRight)); + Assert.Equal(EquipMask.FingerWearRight, + h.Objects.Get(leftRing)!.CurrentlyEquippedLocation); + } + + [Fact] + public void ActivateItem_whenEveryCompatibleSlotIsOccupied_movesRetailPreferredBlockerThenWields() + { + var h = new Harness(); + const uint leftRing = 0x50000B74u; + const uint rightRing = 0x50000B75u; + const uint requestedRing = 0x50000B76u; + EquipMask valid = EquipMask.FingerWearLeft | EquipMask.FingerWearRight; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = leftRing, + Name = "Left Ring", + Type = ItemType.Jewelry, + ValidLocations = valid, + }); + h.Objects.MoveItem(leftRing, Player, -1, EquipMask.FingerWearLeft); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = rightRing, + Name = "Right Ring", + Type = ItemType.Jewelry, + ValidLocations = valid, + }); + h.Objects.MoveItem(rightRing, Player, -1, EquipMask.FingerWearRight); + h.AddContained(requestedRing, item => + { + item.Name = "New Ring"; + item.Type = ItemType.Jewelry; + item.ValidLocations = valid; + }); + + Assert.True(h.Controller.ActivateItem(requestedRing)); + Assert.Equal(new[] { (leftRing, Player, 0) }, h.Puts); + Assert.Empty(h.Wields); + Assert.Equal(new[] { "Moving Left Ring to your backpack" }, h.SystemMessages); + + Assert.True(h.Objects.ApplyConfirmedServerMove(leftRing, Player, 0u, 0)); + + Assert.Equal( + new[] { (requestedRing, (uint)EquipMask.FingerWearLeft) }, + h.Wields); } [Fact] @@ -1400,7 +1467,7 @@ public sealed class ItemInteractionControllerTests } [Fact] - public void InventoryDragOutsideUi_sendsDropAndMovesToWorldOptimistically() + public void InventoryDragOutsideUi_sendsDropAndWaitsForServerPlacement() { var h = new Harness(); h.AddContained(0x50000A07u); @@ -1413,7 +1480,7 @@ public sealed class ItemInteractionControllerTests Assert.True(h.Controller.DropToWorld(payload)); Assert.Equal(new[] { 0x50000A07u }, h.Drops); - Assert.Equal(0u, h.Objects.Get(0x50000A07u)!.ContainerId); + Assert.Equal(Pack, h.Objects.Get(0x50000A07u)!.ContainerId); } /// @@ -1517,7 +1584,7 @@ public sealed class ItemInteractionControllerTests Assert.Empty(h.Gives); Assert.Equal(new[] { item }, h.Drops); - Assert.Equal(0u, h.Objects.Get(item)!.ContainerId); + Assert.Equal(Pack, h.Objects.Get(item)!.ContainerId); } [Theory] @@ -1898,6 +1965,126 @@ public sealed class ItemInteractionControllerTests Assert.False(h.Controller.TryGetPendingInventoryRequest(out _)); } + [Fact] + public void KeyboardPickup_AutoMergesWholeStackBeforeContainerPlacement() + { + var h = new Harness(); + const uint source = 0x70000B01u; + const uint target = 0x50000B02u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = source, + WeenieClassId = 77u, + Name = "World stack", + StackSize = 3, + StackSizeMax = 10, + }); + h.AddContained(target, item => + { + item.WeenieClassId = 77u; + item.StackSize = 5; + item.StackSizeMax = 10; + }); + var attempts = new List<(uint Source, uint Target)>(); + h.Controller.MergeAttempted += (from, into) => attempts.Add((from, into)); + + Assert.True(h.Controller.PlaceWorldItemInBackpack(source)); + + Assert.Equal(new[] { (source, target, 3u) }, h.Merges); + Assert.Equal(new[] { (source, target) }, attempts); + Assert.Empty(h.BackpackPlacements); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending)); + Assert.Equal(InventoryRequestKind.Merge, pending.Kind); + Assert.True(pending.Dispatched); + } + + [Fact] + public void KeyboardPickup_UsesSelectedSplitQuantityAndSearchesNestedPacks() + { + var h = new Harness(); + const uint nestedPack = 0x50000B10u; + const uint source = 0x70000B11u; + const uint target = 0x50000B12u; + h.AddContained(nestedPack, item => item.Type = ItemType.Container); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = source, + WeenieClassId = 88u, + StackSize = 10, + StackSizeMax = 10, + }); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = target, + WeenieClassId = 88u, + StackSize = 8, + StackSizeMax = 10, + }); + h.Objects.MoveItem(target, nestedPack, 0); + h.SelectedObject = source; + h.SplitQuantity.Reset(10u, 2u); + + Assert.True(h.Controller.PlaceWorldItemInBackpack(source)); + + Assert.Equal(new[] { (source, target, 2u) }, h.Merges); + Assert.Empty(h.BackpackPlacements); + } + + [Fact] + public void KeyboardPickup_SkipsPartialMergeTargetAndFallsBackToPlacement() + { + var h = new Harness(); + const uint source = 0x70000B20u; + const uint partialTarget = 0x50000B21u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = source, + WeenieClassId = 99u, + StackSize = 3, + StackSizeMax = 10, + }); + h.AddContained(partialTarget, item => + { + item.WeenieClassId = 99u; + item.StackSize = 9; + item.StackSizeMax = 10; + }); + + Assert.True(h.Controller.PlaceWorldItemInBackpack(source)); + + Assert.Empty(h.Merges); + Assert.Equal(new[] { (source, Player, 0) }, h.BackpackPlacements); + } + + [Fact] + public void TrySplitToContainerDispatchesTheExactSelectedQuantity() + { + var h = new Harness(); + const uint source = 0x50000A30u; + h.AddContained(source, item => item.StackSize = 10); + + Assert.True(h.Controller.TrySplitToContainer(source, Pack, 3u, 2u)); + + Assert.Equal(new[] { (source, Pack, 3u, 2u) }, h.SplitPuts); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending)); + Assert.Equal(InventoryRequestKind.SplitToContainer, pending.Kind); + Assert.Equal(source, pending.ItemId); + } + + [Fact] + public void TrySplitToContainerRejectsZeroAndWholeStackAmounts() + { + var h = new Harness(); + const uint source = 0x50000A31u; + h.AddContained(source, item => item.StackSize = 10); + + Assert.False(h.Controller.TrySplitToContainer(source, Pack, 0u, 0u)); + Assert.False(h.Controller.TrySplitToContainer(source, Pack, 0u, 10u)); + + Assert.Empty(h.SplitPuts); + Assert.False(h.Controller.TryGetPendingInventoryRequest(out _)); + } + [Fact] public void MatchingInventoryFailureReleasesGlobalRequest() { diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs index e80f46f3..e91d56a3 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs @@ -1156,6 +1156,28 @@ public class CharacterStatControllerTests Assert.Equal(RetailUiStateIds.Open, Assert.IsAssignableFrom(child).ActiveRetailStateId)); } + [Fact] + public void ProgrammaticShowTab_UsesTheSameAuthoredStateAsAKeyboardAction() + { + ImportedLayout layout = FixtureLoader.LoadCharacter(); + var attributes = Assert.IsType( + layout.FindElement(CharacterStatController.TabAttribId)); + var skills = Assert.IsType( + layout.FindElement(CharacterStatController.TabSkillsId)); + CharacterStatController.Binding binding = CharacterStatController.Bind( + layout, + SampleData.SampleCharacter, + spriteResolve: id => (id, 16, 16)); + + binding.ShowTab(CharacterStatController.CharacterStatTab.Skills); + + Assert.Equal( + CharacterStatController.CharacterStatTab.Skills, + binding.CurrentTab()); + Assert.Equal(RetailUiStateIds.Closed, attributes.ActiveRetailStateId); + Assert.Equal(RetailUiStateIds.Open, skills.ActiveRetailStateId); + } + /// /// CT3 (2026-08-24): unlike Attributes/Skills (which share ONE mounted /// page and only rebind its content), the Titles page (0x10000539) is a @@ -1267,7 +1289,7 @@ public class CharacterStatControllerTests CharacterSheet sheet = SampleData.SampleCharacter(); Action refresh = CharacterStatController.Bind(layout, () => sheet, - spriteResolve: id => (id, 16, 16)); + spriteResolve: id => (id, 16, 16)).Refresh; ClickTab(layout, left: 92f); var untrained = sheet.Skills.First( diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs index 8e705d6d..64f118de 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs @@ -225,6 +225,47 @@ public sealed class ChatTranscriptRunsTests Assert.Equal(detailed[^1].Text, lines[^1].Text); } + [Fact] + public void OversizedMultilineServerMessageKeepsItsNewestCompleteLines() + { + string response = string.Join( + '\n', + Enumerable.Range(0, 1_500).Select(i => $"@command-{i:D4}")); + var detailed = new List { Plain(response) }; + + List lines = ChatTranscriptRenderer.BuildLines( + detailed, + maxW: 100_000f, + Measure, + accept: null, + defaultColor: LineColor); + + Assert.NotEmpty(lines); + Assert.Equal("@command-1499", lines[^1].Text); + Assert.DoesNotContain(lines, line => line.Text == "@command-0000"); + Assert.All(lines, line => Assert.False(string.IsNullOrWhiteSpace(line.Text))); + } + + [Fact] + public void MultilineServerMessageWithinBudgetRendersEveryAuthoredLine() + { + var detailed = new List + { + Plain("@acecommands\n@help\n@teleport"), + }; + + List lines = ChatTranscriptRenderer.BuildLines( + detailed, + maxW: 100_000f, + Measure, + accept: null, + defaultColor: LineColor); + + Assert.Equal( + new[] { "@acecommands", "@help", "@teleport" }, + lines.Select(line => line.Text)); + } + [Fact] public void TheBudgetIsRetailsOwnNumber() => Assert.Equal(0x2710, ChatTranscriptRenderer.MaxTranscriptCharacters); diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 418b54df..0f640969 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -494,6 +494,28 @@ public class InventoryControllerTests Assert.True(containers.GetItem(0)!.Selected); // square — the bag is also the selected item } + [Fact] + public void DoubleClickOwnedBag_opensOnceOnFirstPress_andNeverRunsGenericUse() + { + var (layout, _, containers, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedBag(objects, 0xCu, slot: 0); + var uses = new List(); + Bind(layout, objects, uses: uses); + + UiItemSlot bag = containers.GetItem(0)!; + bag.OnEvent(new UiEvent(0u, bag, UiEventType.MouseDown)); + bag.OnEvent(new UiEvent(0u, bag, UiEventType.Click)); + bag.OnEvent(new UiEvent(0u, bag, UiEventType.MouseDown)); + bag.OnEvent(new UiEvent(0u, bag, UiEventType.Click)); + bag.OnEvent(new UiEvent(0u, bag, UiEventType.DoubleClick)); + + Assert.Equal(new[] { 0xCu }, uses); + Assert.Null(bag.DoubleClicked); + Assert.True(containers.GetItem(0)!.IsOpenContainer); + Assert.Equal(0xCu, objects.Get(0xCu)!.ObjectId); + } + [Fact] public void MouseDownGridItem_movesSquareImmediately_noWire_keepsOpenContainer() { @@ -684,7 +706,7 @@ public class InventoryControllerTests Workmanship: null); [Fact] - public void Drop_onOccupiedGridCell_insertsBefore_andMovesLocally() + public void Drop_onOccupiedGridCell_insertsBefore_andWaitsForServer() { var (layout, grid, _, _, _, _, _, _) = BuildLayout(); var objects = new ClientObjectTable(); @@ -699,7 +721,7 @@ public class InventoryControllerTests ((IItemListDragHandler)ctrl).HandleDropRelease(grid, bCell, Payload(0xFFFFu)); Assert.Contains((0xFFFFu, Player, 1), puts); // insert-before slot 1, into the open container - Assert.Equal(Player, objects.Get(0xFFFFu)!.ContainerId); // moved locally (instant) + Assert.Equal(0u, objects.Get(0xFFFFu)!.ContainerId); } [Fact] @@ -1316,11 +1338,12 @@ public class InventoryControllerTests StackSizeMax = 100, }); objects.MoveItem(0xAu, Player, 0); + SeedBag(objects, 0xCu, slot: 1); var selection = new SelectionState(); selection.Select(0xAu, SelectionChangeSource.Inventory); var splitQuantity = new StackSplitQuantityState(); splitQuantity.Reset(10u); - splitQuantity.SetValue(1u); + splitQuantity.SetValue(2u); var splits = new List<(uint item, uint container, uint placement, uint amount)>(); var puts = new List<(uint item, uint container, int placement)>(); var ctrl = Bind(layout, objects, puts: puts, splits: splits, @@ -1328,7 +1351,9 @@ public class InventoryControllerTests ctrl.HandleDropRelease(grid, grid.GetItem(5)!, Payload(0xAu)); - Assert.Equal(new[] { (0xAu, Player, 1u, 1u) }, splits); + // Placement counts the main pack's visible loose-item list only; + // side bags occupy the separate selector list and must not shift it. + Assert.Equal(new[] { (0xAu, Player, 1u, 2u) }, splits); Assert.Empty(puts); Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); Assert.Equal(0, objects.Get(0xAu)!.ContainerSlot); @@ -1464,7 +1489,7 @@ public class InventoryControllerTests ((IItemListDragHandler)ctrl).HandleDropRelease(containers, bagCell, Payload(0xFFFFu)); Assert.Contains((0xFFFFu, 0xCu, 0), puts); // into the bag, append (placement 0) - Assert.Equal(0xCu, objects.Get(0xFFFFu)!.ContainerId); + Assert.Equal(0u, objects.Get(0xFFFFu)!.ContainerId); } [Fact] @@ -1483,6 +1508,34 @@ public class InventoryControllerTests ctrl.OnDragOver(grid, grid.GetItem(0)!, Payload(0xFFFFu))); // grid → green } + [Fact] + public void MainPackFullness_countsLooseItems_notSideBags_afterAFreeSlotAppears() + { + var (layout, _, _, top, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = Player, + Type = ItemType.Creature, + ItemsCapacity = 2, + }); + SeedContained(objects, 0xA0u, Player, slot: 0); + SeedContained(objects, 0xA1u, Player, slot: 1); + SeedBag(objects, 0xC0u, slot: 0); + SeedContained(objects, 0xB0u, 0xC0u, slot: 0); + var controller = (IItemListDragHandler)Bind(layout, objects); + UiItemSlot mainPack = top.GetItem(0)!; + + Assert.Equal(ItemDragAcceptance.Reject, + controller.OnDragOver(top, mainPack, Payload(0xB0u))); + + Assert.True(objects.Remove(0xA1u)); + + Assert.Equal(ItemDragAcceptance.Accept, + controller.OnDragOver(top, top.GetItem(0)!, Payload(0xB0u))); + Assert.Equal(0.5f, top.GetItem(0)!.CapacityFill); + } + [Fact] public void GroundPack_rejectsContentsGrid_butEmptyPackSlotAcceptsAndPicksUpAtThatSlot() { @@ -1617,7 +1670,7 @@ public class InventoryControllerTests } [Fact] - public void Drop_thenServerRollback_revertsTheMove() // optimistic + InventoryServerSaveFailed snap-back + public void Drop_thenServerReject_keepsCanonicalPlacement() { var (layout, _, containers, _, _, _, _, _) = BuildLayout(); var objects = new ClientObjectTable(); @@ -1626,11 +1679,10 @@ public class InventoryControllerTests var ctrl = Bind(layout, objects); ((IItemListDragHandler)ctrl).HandleDropRelease(containers, containers.GetItem(0)!, Payload(0xAu)); - Assert.Equal(0xCu, objects.Get(0xAu)!.ContainerId); // moved into the bag optimistically (instant) - - objects.RollbackMove(0xAu); // server rejected (InventoryServerSaveFailed) - Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); // snapped back to the main pack - Assert.Equal(3, objects.Get(0xAu)!.ContainerSlot); // and the original slot + Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); + Assert.False(objects.RejectMove(0xAu, 0x426u)); + Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); + Assert.Equal(3, objects.Get(0xAu)!.ContainerSlot); } // Reads the text of the UiText caption child attached by the controller. diff --git a/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigControllerTests.cs index f7c50a22..31924816 100644 --- a/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigControllerTests.cs @@ -20,9 +20,8 @@ namespace AcDream.App.Tests.UI.Layout; /// /// /// Reworked at the 2026-08-11 combined review (M1/M2/M3/S1/S4): the seam now -/// carries (chord + activation + scope), the M2 fix means -/// only ONE camera InputMap context maps live, and conflicts open a real confirm -/// dialog instead of auto-reassigning silently. +/// carries (chord + activation + scope). Campaign KB gives +/// both camera contexts distinct live identities and removes the store-only tier. /// /// public sealed class KeyboardConfigControllerTests @@ -55,7 +54,16 @@ public sealed class KeyboardConfigControllerTests uint inputMapId, uint actionId, RetailActionClass cls, uint labelHash = 0, uint tooltipHash = 0, params RetailKeyChord[] defaults) => - new(inputMapId, actionId, cls, labelHash, tooltipHash, defaults); + new( + inputMapId, + actionId, + cls, + labelHash == 0 ? 0xDE000000u | (actionId & 0x00FFFFFFu) : labelHash, + tooltipHash, + defaults); + + private static string? ResolveSyntheticString(uint _, uint hash) => + (hash & 0xFF000000u) == 0xDE000000u ? $"Action {hash & 0x00FFFFFFu:X}" : null; private sealed class FakeBindings { @@ -72,6 +80,9 @@ public sealed class KeyboardConfigControllerTests public List InstructionCloses { get; } = new(); public uint NextInstructionContext { get; set; } = 7u; public bool WireInstructions { get; set; } + public string CurrentKeymapFilename { get; set; } = "acdream.keymap"; + public Action? PendingLoadCompleted { get; private set; } + public Action? PendingSaveCompleted { get; private set; } public void Capture(KeyChord? chord) { @@ -103,8 +114,26 @@ public sealed class KeyboardConfigControllerTests BeginCapture: cb => PendingCapture = cb, Save: () => SaveCalls++, Toggle: () => ToggleCalls++, - DisplaySystemMessage: msg => Messages.Add(msg), - NonBindableRefusalText: "cannot overwrite", + ResolveTemplate: (key, variables) => key switch + { + "ID_ActionKeyMap_ButtonLabel" => variables[DatStringResolver.ComputeHash("LABEL")], + "ID_ActionKeyMap_TT_ExistingBinding" => + $"({variables[DatStringResolver.ComputeHash("VALUE")]}) existing binding", + "ID_ActionKeyMap_TT_NewBinding" => "new binding", + "ID_ActionKeyMap_NonUserBindableBinding" => + $"cannot overwrite {variables[DatStringResolver.ComputeHash("KEY")]}", + "ID_ActionKeyMap_OverwriteExistingBinding" => + $"overwrite {variables[DatStringResolver.ComputeHash("KEY")]} " + + variables[DatStringResolver.ComputeHash("ACTION")], + "ID_ActionKeyMap_Binding" => + $"{variables[DatStringResolver.ComputeHash("ACTION")]} " + + $"({variables[DatStringResolver.ComputeHash("KEY")]})", + "ID_ActionKeyMap_OverwriteExistingBindings" => + $"overwrite {variables[DatStringResolver.ComputeHash("KEY")]}\n" + + variables[DatStringResolver.ComputeHash("BINDINGS")], + _ => null, + }, + ShowMessage: msg => Messages.Add(msg), ConfirmOverwrite: (message, onResult) => PendingConfirm = (message, onResult), OpenCaptureInstructions: WireInstructions ? label => @@ -114,11 +143,23 @@ public sealed class KeyboardConfigControllerTests } : null, CloseCaptureInstructions: context => InstructionCloses.Add(context)); + + public KeyboardConfigController.Bindings ToProfileBindings() => + ToBindings() with + { + CurrentKeymapFilename = () => this.CurrentKeymapFilename, + OpenLoadKeymap = completed => PendingLoadCompleted = completed, + OpenSaveKeymap = completed => PendingSaveCompleted = completed, + }; } private static readonly KeyChord ChordW = new(Silk.NET.Input.Key.W, ModifierMask.None); private static readonly KeyChord ChordUp = new(Silk.NET.Input.Key.Up, ModifierMask.None); private static readonly KeyChord ChordA = new(Silk.NET.Input.Key.A, ModifierMask.None); + private static readonly KeyChord LeftMouse = new( + InputDispatcher.MouseButtonToKey(Silk.NET.Input.MouseButton.Left), + ModifierMask.None, + Device: 1); [Fact] public void Bind_Succeeds_AndBuildsOneRowPerSnapshotRow() @@ -133,7 +174,7 @@ public sealed class KeyboardConfigControllerTests ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); var fake = new FakeBindings(); KeyboardConfigController? controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings()); + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings()); Assert.NotNull(controller); Assert.Equal(3, controller!.Rows.Count); @@ -158,7 +199,7 @@ public sealed class KeyboardConfigControllerTests ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); var fake = new FakeBindings(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; Assert.NotNull(controller); UiTabPanel tabHost = Assert.IsType(layout.FindElement(0x1000049Bu)); @@ -188,28 +229,28 @@ public sealed class KeyboardConfigControllerTests } [Fact] - public void Bind_MapsKnownActionsAndLeavesUnknownOnesUnmapped() + public void Bind_MapsEveryRetailActionRow() { var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement), // -> MovementForward - Row(0x10000006, 0x100000A0, RetailActionClass.Emote), // "Bow Deep" -> no InputAction + Row(0x10000006, 0x100000A0, RetailActionClass.Emote), // -> EmoteBowDeep }); ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); var fake = new FakeBindings(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u); Assert.Equal(InputAction.MovementForward, forward.MappedAction); KeyboardConfigController.RowView bowDeep = controller.Rows.Single(r => r.ActionId == 0x100000A0u); - Assert.Null(bowDeep.MappedAction); + Assert.Equal(InputAction.EmoteBowDeep, bowDeep.MappedAction); } [Fact] - public void Bind_SeedsRowFromLiveBindings_MappedAndUnmapped() + public void Bind_SeedsEveryRowFromLiveBindings() { var snapshot = new RetailActionMapSnapshot(new[] { @@ -223,11 +264,14 @@ public sealed class KeyboardConfigControllerTests new(ChordW, InputAction.MovementForward), new(ChordUp, InputAction.MovementForward), }; - fake.Unmapped[(0x10000006u, 0x100000A0u)] = new List { ChordA }; + fake.Mapped[InputAction.EmoteBowDeep] = new List + { + new(ChordA, InputAction.EmoteBowDeep), + }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u); Assert.Equal(new[] { ChordW, ChordUp }, forward.Model.Current); @@ -243,7 +287,7 @@ public sealed class KeyboardConfigControllerTests var fake = new FakeBindings(); ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); Assert.NotEmpty(row.KeyButtons); @@ -257,8 +301,8 @@ public sealed class KeyboardConfigControllerTests Assert.Equal(InputAction.MovementForward, written.Action); Binding onlyBinding = Assert.Single(written.Value); Assert.Equal(ChordW, onlyBinding.Chord); - // No live binding existed at build time — falls back to the Binding - // record's own defaults (Press/Game), same as before M1. + // No live binding existed at build time — falls back to the retail + // action identity's activation/scope metadata. Assert.Equal(ActivationType.Press, onlyBinding.Activation); Assert.Equal(InputScope.Game, onlyBinding.Scope); Assert.Equal("W", row.KeyButtons[0].Label); @@ -272,7 +316,7 @@ public sealed class KeyboardConfigControllerTests fake.Mapped[InputAction.MovementForward] = new List { new(ChordW, InputAction.MovementForward) }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); row.KeyButtons[0].OnClick!.Invoke(); @@ -293,7 +337,7 @@ public sealed class KeyboardConfigControllerTests var fake = new FakeBindings { WireInstructions = true, NextInstructionContext = 42u }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); row.KeyButtons[0].OnClick!.Invoke(); @@ -314,7 +358,7 @@ public sealed class KeyboardConfigControllerTests var fake = new FakeBindings { WireInstructions = true, NextInstructionContext = 9u }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); row.KeyButtons[0].OnClick!.Invoke(); @@ -331,7 +375,7 @@ public sealed class KeyboardConfigControllerTests var fake = new FakeBindings { WireInstructions = true, NextInstructionContext = 0u }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); row.KeyButtons[0].OnClick!.Invoke(); @@ -354,7 +398,7 @@ public sealed class KeyboardConfigControllerTests ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); var requests = new List<(uint LayoutId, uint ElementId)>(); KeyboardConfigController? controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings(), + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings(), resolveTemplateFont: (layoutId, elementId) => { requests.Add((layoutId, elementId)); @@ -382,7 +426,7 @@ public sealed class KeyboardConfigControllerTests }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); Assert.Equal(2, row.Model.Current.Count); @@ -400,7 +444,7 @@ public sealed class KeyboardConfigControllerTests var fake = new FakeBindings(); ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); Assert.Empty(row.Model.Current); @@ -411,17 +455,17 @@ public sealed class KeyboardConfigControllerTests Assert.Empty(fake.MappedSets); } - /// S4 (2026-08-11 review): clicking "Mapping 3" (slot index 2) on a - /// row with NO existing bindings must land the captured chord on display - /// index 2, not collapse it onto index 0. + /// Retail SetBinding clamps a requested slot past the dense + /// current-list tail to Count. Mapping 3 on an empty row therefore appends + /// at Mapping 1. [Fact] - public void KeyButtonClick_OnSparseRow_ThirdSlotLandsOnThirdButton() + public void KeyButtonClick_PastDenseTail_AppendsAtFirstAvailableButton() { var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) }); var fake = new FakeBindings(); ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); Assert.Equal(3, row.KeyButtons.Count); @@ -430,12 +474,10 @@ public sealed class KeyboardConfigControllerTests row.KeyButtons[2].OnClick!.Invoke(); // "Mapping 3" fake.Capture(ChordW); - Assert.Null(row.KeyButtons[0].Label); + Assert.Equal("W", row.KeyButtons[0].Label); Assert.Null(row.KeyButtons[1].Label); - Assert.Equal("W", row.KeyButtons[2].Label); + Assert.Null(row.KeyButtons[2].Label); - // The write to the live seam only ever carries the REAL chord — no - // default(KeyChord) padding leaks into the persisted Binding list. (InputAction Action, IReadOnlyList Value) written = Assert.Single(fake.MappedSets); Binding onlyBinding = Assert.Single(written.Value); Assert.Equal(ChordW, onlyBinding.Chord); @@ -453,7 +495,7 @@ public sealed class KeyboardConfigControllerTests fake.Mapped[InputAction.MovementBackup] = new List { new(ChordA, InputAction.MovementBackup) }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u); KeyboardConfigController.RowView backup = controller.Rows.Single(r => r.ActionId == 0x2Au); @@ -463,6 +505,7 @@ public sealed class KeyboardConfigControllerTests // M3: nothing is applied yet — a confirm dialog is pending. Assert.NotNull(fake.PendingConfirm); + Assert.Equal("overwrite A Action 2A", fake.PendingConfirm?.Message); Assert.DoesNotContain(ChordA, forward.Model.Current); Assert.Contains(ChordA, backup.Model.Current); Assert.Empty(fake.Messages); @@ -473,6 +516,153 @@ public sealed class KeyboardConfigControllerTests Assert.DoesNotContain(ChordA, backup.Model.Current); } + [Fact] + public void Capture_BareShiftConflictWithRetailWalkMode_AlwaysPrompts() + { + var shift = new KeyChord( + Silk.NET.Input.Key.ShiftLeft, + ModifierMask.None); + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(0x4, 0x29, RetailActionClass.Movement), // Move forward + Row(0x4, 0x32, RetailActionClass.Movement), // Toggle walk/run + }); + var fake = new FakeBindings(); + fake.Mapped[InputAction.MovementWalkMode] = + [new Binding( + shift, + InputAction.MovementWalkMode, + ActivationType.Hold, + InputScope.Game)]; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, + snapshot, + MakeTemplateResolver(), + ResolveSyntheticString, + fake.ToBindings())!; + + KeyboardConfigController.RowView forward = controller.Rows.Single( + row => row.ActionId == 0x29u); + forward.KeyButtons[0].OnClick!.Invoke(); + fake.Capture(shift); + + Assert.NotNull(fake.PendingConfirm); + Assert.DoesNotContain(shift, forward.Model.Current); + Assert.Equal( + [shift], + controller.Rows.Single(row => row.ActionId == 0x32u).Model.Current); + } + + [Fact] + public void Capture_ConflictWithMultipleRows_UsesRetailPluralBindingList() + { + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(0x4, 0x29, RetailActionClass.Movement), + Row(0x4, 0x2A, RetailActionClass.Movement), + Row(0x4, 0x2B, RetailActionClass.Movement), + }); + var fake = new FakeBindings(); + fake.Mapped[InputAction.MovementBackup] = + new List { new(ChordA, InputAction.MovementBackup) }; + fake.Mapped[InputAction.MovementStop] = + new List { new(ChordA, InputAction.MovementStop) }; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; + + controller.Rows.Single(row => row.ActionId == 0x29u).KeyButtons[0].OnClick!.Invoke(); + fake.Capture(ChordA); + + Assert.Equal( + "overwrite A\nAction 2A (A)\nAction 2B (A)", + fake.PendingConfirm?.Message); + } + + [Fact] + public void Refresh_UsesRetailExistingAndNewBindingTooltipTemplates() + { + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(0x4, 0x29, RetailActionClass.Movement), + }); + var fake = new FakeBindings(); + fake.Mapped[InputAction.MovementForward] = + new List { new(ChordW, InputAction.MovementForward) }; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; + + KeyboardConfigController.RowView row = Assert.Single(controller.Rows); + Assert.Equal("(W) existing binding", row.KeyButtons[0].TooltipText); + Assert.Equal("new binding", row.KeyButtons[1].TooltipText); + Assert.Equal("new binding", row.KeyButtons[2].TooltipText); + + row.KeyButtons[0].OnRightClick!.Invoke(); + Assert.Equal("new binding", row.KeyButtons[0].TooltipText); + } + + [Fact] + public void Capture_ChordAlreadyInAnotherSlotOfSameRow_IsRetailNoOp() + { + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(0x4, 0x29, RetailActionClass.Movement), + }); + var fake = new FakeBindings(); + fake.Mapped[InputAction.MovementForward] = new List + { + new(ChordW, InputAction.MovementForward), + new(ChordUp, InputAction.MovementForward), + }; + // Prove retail's same-row early return happens before the + // non-user-bindable conflict check too. + fake.Mapped[InputAction.AcdreamToggleAudioMute] = new List + { + new(ChordW, InputAction.AcdreamToggleAudioMute), + }; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; + + KeyboardConfigController.RowView row = Assert.Single(controller.Rows); + row.KeyButtons[2].OnClick!.Invoke(); + fake.Capture(ChordW); + + Assert.Equal(new[] { ChordW, ChordUp }, row.Model.Current); + Assert.Null(fake.PendingConfirm); + Assert.Empty(fake.Messages); + Assert.Empty(fake.MappedSets); + } + + [Fact] + public void Capture_LeftOrRightMouseButton_RemainsArmedUntilSupportedInput() + { + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(0x4, 0x29, RetailActionClass.Movement), + }); + var fake = new FakeBindings { WireInstructions = true }; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; + + KeyboardConfigController.RowView row = Assert.Single(controller.Rows); + row.KeyButtons[0].OnClick!.Invoke(); + fake.Capture(LeftMouse); + + Assert.NotNull(fake.PendingCapture); + Assert.Empty(fake.InstructionCloses); + Assert.Empty(row.Model.Current); + + fake.Capture(ChordW); + + Assert.Null(fake.PendingCapture); + Assert.Equal(new[] { ChordW }, row.Model.Current); + Assert.Equal(new[] { 7u }, fake.InstructionCloses); + } + [Fact] public void Capture_ConflictWithAnotherRow_DeclineLeavesBothRowsUnchanged() { @@ -486,7 +676,7 @@ public sealed class KeyboardConfigControllerTests fake.Mapped[InputAction.MovementBackup] = new List { new(ChordA, InputAction.MovementBackup) }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u); KeyboardConfigController.RowView backup = controller.Rows.Single(r => r.ActionId == 0x2Au); @@ -500,7 +690,71 @@ public sealed class KeyboardConfigControllerTests } [Fact] - public void Capture_ConflictWithNonBindableAcdreamAction_RefusesWithoutADialog() + public void Capture_SharedChordAcrossNonConflictingCombatContexts_KeepsBothBindings() + { + const uint meleeMap = 0x10000003u; + const uint missileMap = 0x10000004u; + var conflicts = new Dictionary> + { + [meleeMap] = new HashSet { meleeMap }, + [missileMap] = new HashSet { missileMap }, + }; + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(meleeMap, 0x1000005Du, RetailActionClass.Combat), + Row(missileMap, 0x100000F1u, RetailActionClass.Combat), + }, conflicts); + var fake = new FakeBindings(); + fake.Mapped[InputAction.CombatAimLow] = + new List { new(ChordA, InputAction.CombatAimLow, Scope: InputScope.MissileCombat) }; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; + + KeyboardConfigController.RowView melee = controller.Rows.Single( + row => row.InputMapId == meleeMap); + KeyboardConfigController.RowView missile = controller.Rows.Single( + row => row.InputMapId == missileMap); + melee.KeyButtons[0].OnClick!.Invoke(); + fake.Capture(ChordA); + + Assert.Null(fake.PendingConfirm); + Assert.Contains(ChordA, melee.Model.Current); + Assert.Contains(ChordA, missile.Model.Current); + } + + [Fact] + public void Capture_SharedChordAcrossDatConflictingContexts_StillPrompts() + { + const uint movementMap = 0x4u; + const uint uiMap = 0x10000009u; + var conflicts = new Dictionary> + { + [movementMap] = new HashSet { movementMap, uiMap }, + }; + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(movementMap, 0x29u, RetailActionClass.Movement), + Row(uiMap, 0x10000019u, RetailActionClass.Ui), + }, conflicts); + var fake = new FakeBindings(); + fake.Mapped[InputAction.ToggleInventoryPanel] = + new List { new(ChordA, InputAction.ToggleInventoryPanel) }; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; + + KeyboardConfigController.RowView movement = controller.Rows.Single( + row => row.InputMapId == movementMap); + movement.KeyButtons[0].OnClick!.Invoke(); + fake.Capture(ChordA); + + Assert.NotNull(fake.PendingConfirm); + Assert.DoesNotContain(ChordA, movement.Model.Current); + } + + [Fact] + public void Capture_ConflictWithNonBindableAcdreamAction_ShowsRetailMessageDialog() { var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) }); var fake = new FakeBindings(); @@ -510,16 +764,17 @@ public sealed class KeyboardConfigControllerTests new List { new(muteChord, InputAction.AcdreamToggleAudioMute) }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView forward = controller.Rows.Single(); forward.KeyButtons[0].OnClick!.Invoke(); fake.Capture(muteChord); - // S1: refused outright, no confirm dialog offered. + // S1: refused outright, with the distinct retail message dialog and no + // overwrite-confirmation dialog. Assert.Null(fake.PendingConfirm); Assert.DoesNotContain(muteChord, forward.Model.Current); - Assert.Contains("cannot overwrite", fake.Messages); + Assert.Contains(fake.Messages, message => message.Contains("cannot overwrite")); Assert.Equal(muteChord, Assert.Single(fake.Mapped[InputAction.AcdreamToggleAudioMute]).Chord); } @@ -540,14 +795,14 @@ public sealed class KeyboardConfigControllerTests new List { new(sharedChord, InputAction.AcdreamToggleAudioMute) }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u); forward.KeyButtons[0].OnClick!.Invoke(); fake.Capture(sharedChord); Assert.Null(fake.PendingConfirm); - Assert.Contains("cannot overwrite", fake.Messages); + Assert.Contains(fake.Messages, message => message.Contains("cannot overwrite")); Assert.DoesNotContain(sharedChord, forward.Model.Current); } @@ -558,7 +813,7 @@ public sealed class KeyboardConfigControllerTests var fake = new FakeBindings(); ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); row.KeyButtons[0].OnClick!.Invoke(); @@ -573,6 +828,102 @@ public sealed class KeyboardConfigControllerTests Assert.Equal(1, fake.ToggleCalls); } + [Fact] + public void LoadFile_ReplacesRowsAndRevertBaseline_AndRefreshesFilename() + { + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(0x4, 0x29, RetailActionClass.Movement), + }); + var fake = new FakeBindings(); + fake.Mapped[InputAction.MovementForward] = + [new Binding(ChordW, InputAction.MovementForward)]; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, + fake.ToProfileBindings())!; + + ((UiButton)layout.FindElement(0x10000027u)!).OnClick!.Invoke(); + Assert.NotNull(fake.PendingLoadCompleted); + + fake.Mapped[InputAction.MovementForward] = + [new Binding(ChordUp, InputAction.MovementForward)]; + fake.CurrentKeymapFilename = "friends.keymap"; + fake.PendingLoadCompleted!(); + + ActionKeyMapOptionRow row = controller.Rows.Single().Model; + Assert.Equal(new[] { ChordUp }, row.Current); + Assert.Equal(new[] { ChordUp }, row.Saved); + Assert.False(row.Changed); + UiText filename = (UiText)layout.FindElement(0x10000028u)!; + Assert.Equal("friends.keymap", Assert.Single(filename.LinesProvider()).Text); + } + + [Fact] + public void SaveAs_RefreshesActiveFilenameOnlyAfterSuccessfulCallback() + { + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(0x4, 0x29, RetailActionClass.Movement), + }); + var fake = new FakeBindings(); + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + _ = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, + fake.ToProfileBindings()); + UiText filename = (UiText)layout.FindElement(0x10000028u)!; + + ((UiButton)layout.FindElement(0x10000029u)!).OnClick!.Invoke(); + Assert.NotNull(fake.PendingSaveCompleted); + Assert.Equal("acdream.keymap", Assert.Single(filename.LinesProvider()).Text); + + fake.CurrentKeymapFilename = "alternate.keymap"; + fake.PendingSaveCompleted!(); + Assert.Equal("alternate.keymap", Assert.Single(filename.LinesProvider()).Text); + } + + [Fact] + public void RevertButton_IsEnabledExactlyWhileWorkingMapDiffersFromSavedMap() + { + var snapshot = new RetailActionMapSnapshot(new[] + { + Row(0x4, 0x29, RetailActionClass.Movement), + }); + var fake = new FakeBindings(); + fake.Mapped[InputAction.MovementForward] = + new List { new(ChordW, InputAction.MovementForward) }; + ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); + KeyboardConfigController controller = KeyboardConfigController.Bind( + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; + UiButton revert = (UiButton)layout.FindElement(0x1000002Bu)!; + + // gmKeyboardUI::OnOptionChanged @ 0x004DA890: Ghosted while clean. + Assert.False(revert.Enabled); + + KeyboardConfigController.RowView row = controller.Rows.Single(); + row.KeyButtons[0].OnClick!.Invoke(); + fake.Capture(ChordUp); + Assert.True(controller.Page.Changed); + Assert.True(revert.Enabled); + + revert.OnClick!.Invoke(); + Assert.Equal(new[] { ChordW }, row.Model.Current); + Assert.False(controller.Page.Changed); + Assert.False(revert.Enabled); + + // Defaults is also a live uncommitted edit when the DAT default does + // not equal the saved user map, and therefore re-enables Revert. + UiButton defaults = (UiButton)layout.FindElement(0x1000002Au)!; + defaults.OnClick!.Invoke(); + Assert.True(controller.Page.Changed); + Assert.True(revert.Enabled); + + UiButton ok = (UiButton)layout.FindElement(0x1000002Cu)!; + ok.OnClick!.Invoke(); + Assert.False(controller.Page.Changed); + Assert.False(revert.Enabled); + } + [Fact] public void CancelButton_RevertsUncommittedEditAndToggles() { @@ -581,7 +932,7 @@ public sealed class KeyboardConfigControllerTests fake.Mapped[InputAction.MovementForward] = new List { new(ChordW, InputAction.MovementForward) }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); row.KeyButtons[0].OnClick!.Invoke(); @@ -607,7 +958,7 @@ public sealed class KeyboardConfigControllerTests fake.Mapped[InputAction.MovementForward] = new List { new(ChordUp, InputAction.MovementForward) }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); Assert.Equal(new[] { ChordUp }, row.Model.Current); @@ -639,7 +990,7 @@ public sealed class KeyboardConfigControllerTests }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; UiButton defaultsButton = (UiButton)layout.FindElement(0x1000002Au)!; defaultsButton.OnClick!.Invoke(); @@ -665,7 +1016,7 @@ public sealed class KeyboardConfigControllerTests }; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView row = controller.Rows.Single(); row.KeyButtons[0].OnClick!.Invoke(); @@ -695,20 +1046,24 @@ public sealed class KeyboardConfigControllerTests Row(0x6, 0x35, RetailActionClass.Camera, defaults: new[] { new RetailKeyChord(0xCB, 0, 0, 3) }), }); var fake = new FakeBindings(); + fake.Mapped[InputAction.CameraRotateLeft] = + [new Binding(ChordA, InputAction.CameraRotateLeft)]; + fake.Mapped[InputAction.CameraAlternateRotateLeft] = + [new Binding( + ChordUp, + InputAction.CameraAlternateRotateLeft, + Scope: InputScope.Camera)]; ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView ctx5 = controller.Rows.Single(r => r.InputMapId == 0x5u); KeyboardConfigController.RowView ctx6 = controller.Rows.Single(r => r.InputMapId == 0x6u); Assert.Equal(InputAction.CameraRotateLeft, ctx5.MappedAction); - Assert.Null(ctx6.MappedAction); // unmapped — no live dual-binding infrastructure (M2) + Assert.Equal(InputAction.CameraAlternateRotateLeft, ctx6.MappedAction); - // Round-2 SHOULD-FIX: an unmapped row with no persisted chords now - // DISPLAYS its DAT defaults (retail shows the arrow keys; blank read - // as "unbound"). Display-only — storage stays untouched until the - // user edits THIS row. + // Both rows display their independent live bindings. Assert.NotEmpty(ctx6.Model.Current); var ctx6InitialDisplay = ctx6.Model.Current.ToArray(); @@ -717,13 +1072,15 @@ public sealed class KeyboardConfigControllerTests fake.Capture(ChordW); Assert.Contains(ChordW, ctx5.Model.Current); Assert.Equal(ctx6InitialDisplay, ctx6.Model.Current); // unchanged by ctx5's edit - Assert.Empty(fake.Unmapped); // ctx6's STORE untouched — display seeding writes nothing + Assert.Empty(fake.Unmapped); ctx6.KeyButtons[0].OnClick!.Invoke(); fake.Capture(ChordA); Assert.Contains(ChordA, ctx6.Model.Current); Assert.Contains(ChordW, ctx5.Model.Current); // ctx5 unaffected by ctx6's edit - Assert.True(fake.Unmapped.ContainsKey((0x6u, 0x35u))); + Assert.Contains(fake.MappedSets, write => + write.Action == InputAction.CameraAlternateRotateLeft + && write.Value.Any(binding => binding.Chord == ChordA)); } [Fact] @@ -756,30 +1113,26 @@ public sealed class KeyboardConfigControllerTests + $"and (0x{mapId:X}, 0x{actionId:X}) — aliasing reintroduces the M2 twin-row clobber."); seen[action] = (mapId, actionId); } - Assert.True(seen.Count > 100, $"sanity: only {seen.Count} mapped actions seen"); + Assert.Equal(306, seen.Count); } // ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ─────────── [Fact] - public void UnmappedRows_DimTheirCaption_MappedRowsStayWhite() + public void EveryRetailRowCaptionIsEnabled() { - // AP-203's store-only set: a row whose RetailActionIdentityTable - // lookup fails (MappedAction null -- mostly Emotes/CharacterSettings) - // never reaches the InputDispatcher, so its caption dims. Wiring a - // future mapping for "Bow Deep" (or any other unmapped row) means - // this assertion flips from StoreOnlyCaptionColor to Vector4.One -- - // a conscious edit, not a silent pass. + // Campaign KB removes AP-203's store-only set. Every DAT row now has + // a live dispatcher identity and uses the enabled retail caption color. var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement), // -> MovementForward (mapped) - Row(0x10000006, 0x100000A0, RetailActionClass.Emote), // "Bow Deep" -> unmapped + Row(0x10000006, 0x100000A0, RetailActionClass.Emote), // -> EmoteBowDeep }); ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); var fake = new FakeBindings(); KeyboardConfigController controller = KeyboardConfigController.Bind( - layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; + layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!; KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u); Assert.NotNull(forward.MappedAction); @@ -787,9 +1140,9 @@ public sealed class KeyboardConfigControllerTests Assert.Equal(Vector4.One, forwardCaption.DefaultColor); KeyboardConfigController.RowView bowDeep = controller.Rows.Single(r => r.ActionId == 0x100000A0u); - Assert.Null(bowDeep.MappedAction); + Assert.Equal(InputAction.EmoteBowDeep, bowDeep.MappedAction); UiText bowDeepCaption = RowCaption(bowDeep); - Assert.Equal(UiRenderContext.StoreOnlyCaptionColor, bowDeepCaption.DefaultColor); + Assert.Equal(Vector4.One, bowDeepCaption.DefaultColor); } /// The row's synthesized caption (composed beside the authored key diff --git a/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigInstalledDatConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigInstalledDatConformanceTests.cs new file mode 100644 index 00000000..e267538b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigInstalledDatConformanceTests.cs @@ -0,0 +1,184 @@ +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.Content; +using AcDream.Core.Input; +using AcDream.Content; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Production-shaped installed-DAT gate for #446. The Core conformance lane +/// proves the 306 identities/defaults; this gate proves the actual retained +/// screen can import its authored layout and row template and expose every one +/// of those identities as a live three-slot row. +/// +[Trait("Lane", "InstalledDat")] +public sealed class KeyboardConfigInstalledDatConformanceTests +{ + [Fact] + public void InstalledEorLayout_MountsEveryBindableActionAsALiveRow() + { + string? datDir = ResolveDatDir(); + if (datDir is null) + Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); + + using var dats = new AcDream.App.Tests.BoundedTestDatCollection(datDir); + var strings = new DatStringResolver(dats); + ElementInfo? info = LayoutImporter.ImportInfos( + dats, + KeyboardConfigController.LayoutId); + Assert.NotNull(info); + + ImportedLayout layout = LayoutImporter.Build( + info!, + _ => (0u, 0, 0), + datFont: null, + fontResolve: null, + strings.Resolve); + RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read( + (IDatObjectSource)dats); + Assert.NotNull(snapshot); + + KeyBindings live = KeyBindings.RetailDefaults(); + KeyboardConfigController? controller = KeyboardConfigController.Bind( + layout, + snapshot!, + templateResolver: (templateLayoutId, templateElementId) => + { + ElementInfo? template = LayoutImporter.ImportInfos( + dats, + templateLayoutId, + templateElementId); + return template is null + ? null + : LayoutImporter.Build( + template, + _ => (0u, 0, 0), + datFont: null, + fontResolve: null, + strings.Resolve, + templateLayoutId).Root; + }, + resolveString: (tableId, stringId) => strings.Resolve(tableId, stringId), + new KeyboardConfigController.Bindings( + CurrentForAction: action => live.ForAction(action).ToArray(), + SetForAction: (_, _) => { }, + CurrentForUnmapped: _ => Array.Empty(), + SetForUnmapped: (_, _) => { }, + BeginCapture: _ => { }, + Save: () => { }, + Toggle: () => { }, + ResolveTemplate: (key, variables) => + strings.ResolveTemplate(0x23000004u, key, variables), + ShowMessage: _ => { }, + ConfirmOverwrite: (_, _) => { }, + CurrentKeymapFilename: () => "acdream.keymap", + OpenLoadKeymap: completed => completed(), + OpenSaveKeymap: completed => completed())); + + Assert.NotNull(controller); + Assert.Equal(306, snapshot!.Rows.Count); + Assert.Equal(snapshot.Rows.Count, controller!.Rows.Count); + Assert.Equal(306, controller.Page.Rows.Count); + Assert.All(controller.Rows, row => + { + Assert.NotNull(row.MappedAction); + Assert.False(string.IsNullOrWhiteSpace(row.Label)); + Assert.Equal(3, row.KeyButtons.Count); + Assert.All(row.KeyButtons, button => + { + Assert.NotNull(button.OnClick); + Assert.NotNull(button.OnRightClick); + Assert.False(string.IsNullOrWhiteSpace(button.TooltipText)); + }); + }); + Assert.Equal( + snapshot.Rows.Select(static row => (row.InputMapId, row.ActionId)).ToHashSet(), + controller.Rows.Select(static row => (row.InputMapId, row.ActionId)).ToHashSet()); + + uint key = DatStringResolver.ComputeHash("KEY"); + uint action = DatStringResolver.ComputeHash("ACTION"); + uint bindings = DatStringResolver.ComputeHash("BINDINGS"); + Assert.Equal( + "'Ctrl+M' is currently bound to a non user-bindable action. Please select a different binding.", + strings.ResolveTemplate( + 0x23000004u, + "ID_ActionKeyMap_NonUserBindableBinding", + new Dictionary { [key] = "Ctrl+M" })); + Assert.Equal( + "'A' is currently bound to 'Move Backward'. Do you wish to erase that binding?", + strings.ResolveTemplate( + 0x23000004u, + "ID_ActionKeyMap_OverwriteExistingBinding", + new Dictionary + { + [key] = "A", + [action] = "Move Backward", + })); + Assert.Equal( + "'A' conflicts with the following bindings:\n'Move Backward' ('A')\n'Turn Right' ('A')\nDo you wish to erase those bindings?", + strings.ResolveTemplate( + 0x23000004u, + "ID_ActionKeyMap_OverwriteExistingBindings", + new Dictionary + { + [key] = "A", + [bindings] = "'Move Backward' ('A')\n'Turn Right' ('A')", + })); + + foreach (uint buttonId in new[] + { + 0x10000027u, // Load File + 0x10000029u, // Save As + 0x1000002Au, // Defaults + 0x1000002Bu, // Revert + 0x1000002Cu, // OK + 0x1000002Du, // Cancel + }) + { + UiButton button = Assert.IsType(layout.FindElement(buttonId)); + Assert.NotNull(button.OnClick); + } + + UiText filename = Assert.IsType(layout.FindElement(0x10000028u)); + Assert.Equal("acdream.keymap", Assert.Single(filename.LinesProvider()).Text); + + // The same installed catalog must contain the type-7 presenter retail's + // Load File button opens: root 0x1F, menu 0x21, accept/reject 0x22/0x23. + uint dialogLayoutId = RetailDataIdResolver.Resolve(dats, 2u, 5u); + Assert.NotEqual(0u, dialogLayoutId); + ElementInfo? menuInfo = LayoutImporter.ImportInfos( + dats, + dialogLayoutId, + RetailConfirmationMenuDialogView.RootElementId); + Assert.NotNull(menuInfo); + ImportedLayout menuLayout = LayoutImporter.Build( + menuInfo!, _ => (0u, 0, 0), null, null, strings.Resolve); + Assert.IsType(menuLayout.Root); + UiMenu catalogMenu = Assert.IsType(menuLayout.FindElement( + RetailConfirmationMenuDialogView.MenuElementId)); + Assert.NotEqual(0u, catalogMenu.NormalSprite); + Assert.NotEqual(0u, catalogMenu.PressedSprite); + Assert.NotEqual(0u, catalogMenu.PopupBgSprite); + Assert.NotEqual(0u, catalogMenu.ItemNormalSprite); + Assert.IsType(menuLayout.FindElement( + RetailConfirmationMenuDialogView.AcceptButtonId)); + Assert.IsType(menuLayout.FindElement( + RetailConfirmationMenuDialogView.RejectButtonId)); + } + + private static string? ResolveDatDir() + { + string? fromEnvironment = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + if (!string.IsNullOrWhiteSpace(fromEnvironment) && Directory.Exists(fromEnvironment)) + return fromEnvironment; + + string installed = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", + "Asheron's Call"); + return Directory.Exists(installed) ? installed : null; + } + +} diff --git a/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigLiveMountProbeTests.cs b/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigLiveMountProbeTests.cs index 430e2114..e37b83ee 100644 --- a/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigLiveMountProbeTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/KeyboardConfigLiveMountProbeTests.cs @@ -221,6 +221,13 @@ public sealed class KeyboardConfigLiveMountProbeTests string[] keys = { "ID_ActionKeyMap_MapInstructions", + "ID_ActionKeyMap_Binding", + "ID_ActionKeyMap_ButtonLabel", + "ID_ActionKeyMap_NonUserBindableBinding", + "ID_ActionKeyMap_OverwriteExistingBinding", + "ID_ActionKeyMap_OverwriteExistingBindings", + "ID_ActionKeyMap_TT_ExistingBinding", + "ID_ActionKeyMap_TT_NewBinding", "ID_KeyDescDelimiter", "ID_KeyNameWithSubControl", "ID_KeyMapCantOverwriteReadOnlyKeymap_Label", @@ -251,7 +258,11 @@ public sealed class KeyboardConfigLiveMountProbeTests } // Candidate variable-name hashes for the MapInstructions template slot. - foreach (string candidate in new[] { "ACTION", "NAME", "KEY", "SUBCONTROL", "PLAYER", "COMMAND" }) + foreach (string candidate in new[] + { + "ACTION", "BINDINGS", "KEY", "LABEL", "VALUE", + "NAME", "SUBCONTROL", "PLAYER", "COMMAND", + }) Console.WriteLine( $"[kbstr] hash('{candidate}') = 0x{DatStringResolver.ComputeHash(candidate):X8}"); diff --git a/tests/AcDream.App.Tests/UI/Layout/MapHousePanelControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/MapHousePanelControllerTests.cs index f68475fe..f96352b6 100644 --- a/tests/AcDream.App.Tests/UI/Layout/MapHousePanelControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/MapHousePanelControllerTests.cs @@ -214,6 +214,22 @@ public sealed class MapHousePanelControllerTests Assert.Contains("house-shown", calls); } + [Fact] + public void ShowMap_UsesTheSameAuthoredTabStateAsAClick() + { + ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos(); + ImportedLayout layout = FixtureLoader.LoadMapHouseHost(); + MapHousePanelController controller = MapHousePanelController.Bind( + rootInfo, layout, MakeCallbacks())!; + controller.ActivateTabs(); + controller.TabPanel.SwitchTo(0x100001F7u); // House + + controller.ShowMap(); + + Assert.True(controller.IsShowingMap); + Assert.False(controller.IsShowingHouse); + } + [Fact] public void CloseButton_InvokesToggle() { diff --git a/tests/AcDream.App.Tests/UI/Layout/OptionsPanelControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/OptionsPanelControllerTests.cs index d0cad449..8efdc9fb 100644 --- a/tests/AcDream.App.Tests/UI/Layout/OptionsPanelControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/OptionsPanelControllerTests.cs @@ -162,6 +162,22 @@ public sealed class OptionsPanelControllerTests Assert.Equal(["flush"], flushes); } + [Fact] + public void ShowGameplay_UsesTheSameAuthoredTabStateAsAClick() + { + ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost(); + var calls = new List(); + OptionsPanelController controller = OptionsPanelController.Bind( + layout, MakeCallbacks(calls))!; + controller.ActivateTabs(); + controller.TabPanel.SwitchTo(0x10000211u); // Character + + controller.ShowGameplay(); + + Assert.True(controller.IsShowingGameplay); + Assert.Equal(0x10000212u, controller.TabPanel.ActivePageElementId); + } + [Fact] public void WholeWindowHide_RevertsCurrentlyActivePage() { diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs index ff74b09f..851c8241 100644 --- a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs @@ -254,7 +254,7 @@ public class PaperdollControllerTests } [Fact] - public void HandleDropRelease_wields_optimistically_and_sends_wire() + public void HandleDropRelease_sendsWieldAndWaitsForServerPlacement() { var (layout, lists) = BuildLayout(); var objects = new ClientObjectTable(); @@ -263,8 +263,8 @@ public class PaperdollControllerTests var ctrl = Bind(layout, objects, wields); var payload = new ItemDragPayload(0xD01u, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell); ctrl.HandleDropRelease(lists[HeadSlot], lists[HeadSlot].Cell, payload); - Assert.Equal(EquipMask.HeadWear, objects.Get(0xD01u)!.CurrentlyEquippedLocation); // equipped instantly - Assert.Equal(Player, objects.Get(0xD01u)!.ContainerId); // contained-by-wielder (the optimistic wield is ContainerId-based; it does NOT write WielderId) + Assert.Equal(EquipMask.None, objects.Get(0xD01u)!.CurrentlyEquippedLocation); + Assert.Equal(Pack, objects.Get(0xD01u)!.ContainerId); Assert.Single(wields); Assert.Equal((0xD01u, (uint)EquipMask.HeadWear), wields[0]); // GetAndWieldItem wire } @@ -334,10 +334,10 @@ public class PaperdollControllerTests Assert.Equal(EquipMask.None, objects.Get(sword)!.CurrentlyEquippedLocation); Assert.Equal(new[] { "Moving Shortbow to your backpack" }, messages); - objects.MoveItem(bow, Player, 0, EquipMask.None); + Assert.True(objects.ApplyConfirmedServerMove(bow, Player, 0u, 0)); Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, wields); - Assert.Equal(EquipMask.MeleeWeapon, + Assert.Equal(EquipMask.None, objects.Get(sword)!.CurrentlyEquippedLocation); } @@ -371,7 +371,7 @@ public class PaperdollControllerTests ctrl.HandleDropRelease(lists[ChestSlot], lists[ChestSlot].Cell, payload); Assert.Equal((uint)coatMask, wields[0].mask); - Assert.Equal(coatMask, objects.Get(0xE02u)!.CurrentlyEquippedLocation); + Assert.Equal(EquipMask.None, objects.Get(0xE02u)!.CurrentlyEquippedLocation); } [Fact] @@ -392,7 +392,7 @@ public class PaperdollControllerTests ctrl.HandleDropRelease(lists[ChestArmorSlot], lists[ChestArmorSlot].Cell, payload); Assert.Equal((uint)hauberkMask, wields[0].mask); - Assert.Equal(hauberkMask, objects.Get(0xE03u)!.CurrentlyEquippedLocation); + Assert.Equal(EquipMask.None, objects.Get(0xE03u)!.CurrentlyEquippedLocation); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs index 17ac70da..6bea5ec7 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailDialogFactoryTests.cs @@ -55,6 +55,42 @@ public sealed class RetailDialogFactoryTests Assert.False(factory.IsOpen); } + [Fact] + public void ConfirmationMenu_ReturnsSelectedIndex_AndRejectReturnsMinusOne() + { + var root = new UiRoot { Width = 800f, Height = 600f }; + var layouts = new List(); + var factory = new RetailDialogFactory(root, type => + { + ImportedLayout layout = BuildDialogLayout(type); + layouts.Add(layout); + return layout; + }); + int? result = null; + + factory.MakeConfirmationMenu( + new[] { "acdream.keymap", "friends.keymap" }, + selectedIndex: 1, + data => result = data.GetInt32(RetailDialogProperty.MenuSelection)); + + ImportedLayout first = Assert.Single(layouts); + UiMenu menu = Assert.IsType( + first.FindElement(RetailConfirmationMenuDialogView.MenuElementId)); + Assert.Equal(1, menu.Selected); + menu.Selected = 0; + Button(first, RetailConfirmationMenuDialogView.AcceptButtonId).OnClick!(); + Assert.Equal(0, result); + + result = null; + factory.MakeConfirmationMenu( + new[] { "acdream.keymap" }, + selectedIndex: 0, + data => result = data.GetInt32(RetailDialogProperty.MenuSelection)); + ImportedLayout second = layouts[^1]; + Button(second, RetailConfirmationMenuDialogView.RejectButtonId).OnClick!(); + Assert.Equal(-1, result); + } + [Fact] public void SameQueuePresentsFifoUsingFreshLiveRoots() { @@ -691,6 +727,7 @@ public sealed class RetailDialogFactoryTests { RetailDialogType.Message => 0x17u, RetailDialogType.ConfirmationTextInput => 0x15u, + RetailDialogType.ConfirmationMenu => 0x14u, RetailDialogType.Wait => 0x19u, _ => 0x13u, }; @@ -708,15 +745,18 @@ public sealed class RetailDialogFactoryTests Width = 400f, Height = type == RetailDialogType.ConfirmationTextInput ? 125f : 95f, }; - popup.Children.Add(new ElementInfo + if (type != RetailDialogType.ConfirmationMenu) { - Id = 0x3Eu, - Type = 12u, - X = 15f, - Y = 15f, - Width = 370f, - Height = 18f, - }); + popup.Children.Add(new ElementInfo + { + Id = 0x3Eu, + Type = 12u, + X = 15f, + Y = 15f, + Width = 370f, + Height = 18f, + }); + } if (type == RetailDialogType.Message) { popup.Children.Add(new ElementInfo @@ -793,6 +833,36 @@ public sealed class RetailDialogFactoryTests Height = 32f, }); } + else if (type == RetailDialogType.ConfirmationMenu) + { + popup.Children.Add(new ElementInfo + { + Id = RetailConfirmationMenuDialogView.MenuElementId, + Type = 6u, + X = 80f, + Y = 15f, + Width = 240f, + Height = 24f, + }); + popup.Children.Add(new ElementInfo + { + Id = RetailConfirmationMenuDialogView.AcceptButtonId, + Type = 1u, + X = 80f, + Y = 48f, + Width = 80f, + Height = 32f, + }); + popup.Children.Add(new ElementInfo + { + Id = RetailConfirmationMenuDialogView.RejectButtonId, + Type = 1u, + X = 240f, + Y = 48f, + Width = 80f, + Height = 32f, + }); + } root.Children.Add(popup); return LayoutImporter.Build(root, _ => (0u, 0, 0), null); } diff --git a/tests/AcDream.App.Tests/UI/Layout/RetailKeyNamesTests.cs b/tests/AcDream.App.Tests/UI/Layout/RetailKeyNamesTests.cs index af85104f..fc36a0f0 100644 --- a/tests/AcDream.App.Tests/UI/Layout/RetailKeyNamesTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/RetailKeyNamesTests.cs @@ -54,9 +54,8 @@ public sealed class RetailKeyNamesTests [Fact] public void SelfModifier_ShowsOnlyTheKeyName_NeverShiftPlusShiftLeft() { - // acdream's wire-side chord for retail's bare DIK_LSHIFT walk-mode row - // carries the self-modifier bit; retail's QualifiedControl has - // meta-mode 0 and displays just the key. + // Also accept a legacy self-modifier bit while migrated JSON is read; + // retail's exact row has meta-mode 0 and displays just the key. var names = new RetailKeyNames( NoStrings, osKeyName: (scan, _) => scan == 0x2A ? "SKIFT" : null); @@ -130,15 +129,37 @@ public sealed class RetailKeyNamesTests } [Fact] - public void ControlsOutsideTheDikTable_KeepTheEnumSpelling() + public void KeymapInterchangeControls_UseTheirRetailDikNames() { var names = new RetailKeyNames(NoStrings, osKeyName: (_, _) => null); - // Key.F13 never appears in the DAT's 84 observed scan codes. Assert.Equal("F13", names.Describe(new KeyChord(Key.F13, ModifierMask.None))); Assert.Equal( - "Shift+F13", + "LSHIFT+F13", names.Describe(new KeyChord(Key.F13, ModifierMask.Shift))); + Assert.Equal( + "LWIN+F13", + names.Describe(new KeyChord(Key.F13, ModifierMask.Win))); + } + + [Fact] + public void MouseButtonUsesRetailSemanticTableThenReadableFallback() + { + var authored = new RetailKeyNames( + Table((RetailKeyNames.KeyNameTableId, "DIMOFS_BUTTON0", "Primary Mouse")), + osKeyName: (_, _) => null); + var fallback = new RetailKeyNames(NoStrings, osKeyName: (_, _) => null); + var left = new KeyChord( + InputDispatcher.MouseButtonToKey(MouseButton.Left), + ModifierMask.None, + Device: 1); + var rightWithCtrl = new KeyChord( + InputDispatcher.MouseButtonToKey(MouseButton.Right), + ModifierMask.Ctrl, + Device: 1); + + Assert.Equal("Primary Mouse", authored.Describe(left)); + Assert.Equal("LCONTROL+Mouse Button 2", fallback.Describe(rightWithCtrl)); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs index 20f8e9ee..46069cf0 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs @@ -94,6 +94,8 @@ public class SelectedObjectControllerTests public readonly Dictionary HasHealthMap = new(); public readonly Dictionary ManaMap = new(); public readonly Dictionary StackMap = new(); + public readonly Dictionary CoinstackMap = new(); + public int CoinTotal; // Slice 6.2: vendor-owned split-exempt predicate — see // SelectedObjectController.Bind's isVendorSplitExempt parameter. public readonly Dictionary VendorSplitExemptMap = new(); @@ -139,7 +141,9 @@ public class SelectedObjectControllerTests { if (ObjectUpdatedHandler == h) ObjectUpdatedHandler = null; }, - isVendorSplitExempt: g => VendorSplitExemptMap.TryGetValue(g, out var v) && v); + isVendorSplitExempt: g => VendorSplitExemptMap.TryGetValue(g, out var v) && v, + isCoinstack: g => CoinstackMap.TryGetValue(g, out var v) && v, + coinTotal: () => CoinTotal); } // ── B1: Bind initialisation ────────────────────────────────────────────── @@ -165,6 +169,25 @@ public class SelectedObjectControllerTests Assert.True(nameEl.ZOrder > 1000, "name element must be floated above the overlay/meter z-order"); } + [Fact] + public void OwnedCoinstack_usesRetailsExactStackNameAndTotalFormat() + { + var (layout, nameEl, _, _) = FakeLayout(); + var h = new Harness { CoinTotal = 12_345 }; + const uint coins = 0x50000111u; + h.NameMap[coins] = "Pyreals"; + h.StackMap[coins] = 2_345u; + h.OwnedMap[coins] = true; + h.CoinstackMap[coins] = true; + h.Bind(layout); + + h.FireSelection(coins); + + Assert.Equal( + "2345 Pyreals (of 12345)", + nameEl.Children.OfType().First().LinesProvider().Single().Text); + } + [Fact] public void Bind_nameLinesProvider_yieldsEmpty_whenNothingSelected() { diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellcastingShortcutInputTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellcastingShortcutInputTests.cs new file mode 100644 index 00000000..7027e772 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/SpellcastingShortcutInputTests.cs @@ -0,0 +1,47 @@ +using AcDream.App.UI.Layout; +using AcDream.UI.Abstractions.Input; + +namespace AcDream.App.Tests.UI.Layout; + +public sealed class SpellcastingShortcutInputTests +{ + [Fact] + public void EveryRetailFavoriteSpellSlotHasALiveConsumer() + { + InputAction[] slots = RetailActionIdentityTable.Map + .Where(entry => entry.Key.InputMapId == 0x10000005u) + .Select(entry => entry.Value) + .Where(action => action.ToString().StartsWith( + "UseSpellSlot_", + StringComparison.Ordinal)) + .ToArray(); + + Assert.Equal(12, slots.Length); + Assert.Equal( + Enumerable.Range(0, 12), + slots.Select(action => + { + Assert.True( + SpellcastingUiController.TryMapSpellShortcut( + action, + out int index), + $"No favorite-spell consumer for {action}"); + return index; + }).Order()); + } + + [Theory] + [InlineData(InputAction.UseSpellSlot_1, 0)] + [InlineData(InputAction.UseSpellSlot_9, 8)] + [InlineData(InputAction.UseSpellSlot_10, 9)] + [InlineData(InputAction.UseSpellSlot_11, 10)] + [InlineData(InputAction.UseSpellSlot_12, 11)] + public void AllRetailSpellSlotsMapToFavoriteIndex( + InputAction action, + int expectedIndex) + { + Assert.True( + SpellcastingUiController.TryMapSpellShortcut(action, out int index)); + Assert.Equal(expectedIndex, index); + } +} diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarInputControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarInputControllerTests.cs index 17ad3a13..8223438b 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarInputControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarInputControllerTests.cs @@ -5,11 +5,41 @@ namespace AcDream.App.Tests.UI.Layout; public sealed class ToolbarInputControllerTests { + [Fact] + public void EveryRetailQuickslotRowHasALiveToolbarConsumer() + { + InputAction[] actions = RetailActionIdentityTable.Map + .Where(entry => entry.Key.InputMapId == 0x1000000Cu) + .OrderBy(entry => entry.Key.ActionId) + .Select(entry => entry.Value) + .ToArray(); + + Assert.Equal(28, actions.Length); + Assert.Equal(InputAction.CreateShortcut, actions[21]); + foreach (InputAction action in actions) + { + if (action == InputAction.CreateShortcut) + continue; + + Assert.True( + ToolbarInputController.TryMapShortcut( + action, + out int slot, + out _), + $"No toolbar consumer for {action}"); + Assert.InRange(slot, 0, 17); + } + } + [Theory] [InlineData(InputAction.UseQuickSlot_1, 0, true)] [InlineData(InputAction.UseQuickSlot_9, 8, true)] [InlineData(InputAction.SelectQuickSlot_1, 0, false)] [InlineData(InputAction.SelectQuickSlot_9, 8, false)] + [InlineData(InputAction.UseQuickSlot_10, 9, true)] + [InlineData(InputAction.UseQuickSlot_11, 10, true)] + [InlineData(InputAction.UseQuickSlot_12, 11, true)] + [InlineData(InputAction.UseQuickSlot_13, 12, true)] [InlineData(InputAction.UseQuickSlot_14, 13, true)] [InlineData(InputAction.UseQuickSlot_18, 17, true)] public void ShortcutActions_mapRetailSlotAndIntent(InputAction action, int slot, bool use) diff --git a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs index 389fafc2..c9082885 100644 --- a/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/VendorUiControllerTests.cs @@ -206,6 +206,7 @@ public sealed class VendorUiControllerTests public readonly List<(uint VendorGuid, uint ItemGuid, int Amount, uint AlternateCurrencyId)> Buys = new(); public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items, uint AlternateCurrencyId)> BuyAlls = new(); public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items)> Sells = new(); + public readonly List<(uint Item, uint Container, uint Placement, uint Amount)> SplitPuts = new(); public readonly List SystemMessages = new(); public readonly ItemInteractionController ItemInteraction; public readonly RetailDialogFactory Dialogs; @@ -365,6 +366,9 @@ public sealed class VendorUiControllerTests sendWield: null, sendDrop: null, sendExamine: Examines.Add, + systemMessage: SystemMessages.Add, + sendSplitToContainer: (item, container, placement, amount) => + SplitPuts.Add((item, container, placement, amount)), sendBuy: (vendorGuid, itemGuid, amount, alternateCurrencyId) => { Buys.Add((vendorGuid, itemGuid, amount, alternateCurrencyId)); @@ -655,6 +659,47 @@ public sealed class VendorUiControllerTests GetText(h.ItemCostText)); } + [Fact] + public void AlternateCurrencyPurchaseUpdatesImmediatelyThenReconcilesToInventory() + { + var h = new Harness(); + const uint currencyWcid = 0x12345678u; + const uint currencyGuid = 0x60000A01u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = currencyGuid, + WeenieClassId = currencyWcid, + Name = "Colosseum Coin", + Type = ItemType.Misc, + StackSize = 10, + }); + h.Objects.MoveItem(currencyGuid, Harness.PlayerGuid, 0); + h.State.Apply( + VendorGuid, + Profile(sellRate: 1.0f, altCurrency: currencyWcid, altName: "Colosseum Coins", altAmount: 10u), + new[] + { + new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 2), + }); + + h.BuyButton.OnClick!.Invoke(); + + Assert.Contains("You have 8 Colosseum Coins.", GetText(h.ItemCostText)); + Assert.Equal("You have 8 Colosseum Coins.", GetText(h.BuyPurseText)); + + // An unrelated appraisal/property refresh on the currency object is + // not an authoritative count response and must not erase m_last_sale. + Assert.True(h.Objects.UpdateIntProperty(currencyGuid, 0x7FFFu, 1)); + Assert.Contains("You have 8 Colosseum Coins.", GetText(h.ItemCostText)); + + // The server's stack update replaces the optimistic m_last_sale + // subtraction with the canonical inventory count without bouncing + // the displayed purse back to the stale vendor snapshot. + Assert.True(h.Objects.UpdateStackSize(currencyGuid, 8, value: 0)); + Assert.Contains("You have 8 Colosseum Coins.", GetText(h.ItemCostText)); + Assert.Equal("You have 8 Colosseum Coins.", GetText(h.BuyPurseText)); + } + [Fact] public void NoSelection_DisablesBuyButton_SelectionEnablesIt() { @@ -1850,6 +1895,29 @@ public sealed class VendorUiControllerTests Assert.Empty(h.Buys); } + [Fact] + public void DoubleClickStagedBuyingRow_RemovesOneUnitAndReportsRetailsNotice() + { + var h = new Harness(); + h.State.Apply(VendorGuid, Profile(), new[] + { + new VendorShopItem( + StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000, + DescStackSize: 100), + }); + h.SplitQuantity.Reset(100u, initialValue: 3u); + h.AddButton.OnClick!.Invoke(); + + h.BuyingList.GetItem(0)!.DoubleClicked!.Invoke(); + + Assert.Equal(1, h.BuyingList.GetNumUIItems()); + Assert.Equal(StackedItemGuid, h.Selection.SelectedObjectId); + Assert.Equal(new[] { "Removing Arrows from shopping list" }, h.SystemMessages); + h.BuyAllButton.OnClick!.Invoke(); + (_, IReadOnlyList<(int Amount, uint ItemGuid)> items, _) = Assert.Single(h.BuyAlls); + Assert.Equal(new[] { (2, StackedItemGuid) }, items); + } + /// /// F9 (Slice 6b/6c review): clicking a staged Buying-tab row must /// visibly move the highlight — a prior version of this port only @@ -1887,12 +1955,19 @@ public sealed class VendorUiControllerTests // Slice 6c — Selling tab drag-to-sell staging // ══════════════════════════════════════════════════════════════════════ - private static void MakePlayerOwned(Harness h, uint guid, ItemType type, int value, int stackSize = 1) + private static void MakePlayerOwned( + Harness h, + uint guid, + ItemType type, + int value, + int stackSize = 1, + uint weenieClassId = 0u) { h.Objects.AddOrUpdate(new ClientObject { ObjectId = guid, Name = $"Item {guid:X8}", + WeenieClassId = weenieClassId, Type = type, Value = value, StackSize = stackSize, @@ -2041,29 +2116,79 @@ public sealed class VendorUiControllerTests } /// - /// F6 (Slice 6b/6c review, byte-verified): sell staging ALWAYS records - /// the item's FULL stack — retail's AddItemToSell stages via a - /// LITERAL -1 "full stack" argument - /// (gmVendorUI::AddItem(..., -1, ...), pc:203595), never a - /// slider read. A prior version of this port read the LIVE split - /// slider here instead — this proves a PARTIAL slider selection at - /// drop time does not leak into the staged (or sent) quantity. + /// Retail's AcceptDragObject splits the selected amount first, retains + /// the source as a temporary staging row, then substitutes the newly + /// created split stack before Sell All is sent. /// [Fact] - public void HandleDropRelease_StackableItem_StagesTheFullStackIgnoringTheLiveSlider() + public void HandleDropRelease_PartialStack_SplitsThenStagesTheNewExactStack() { var h = new Harness(); h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty()); - MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.MissileWeapon, 100, stackSize: 20); + const uint wcid = 0x2345u; + const uint splitGuid = 0x60000222u; + MakePlayerOwned( + h, + PlayerOwnedWeaponGuid, + ItemType.MissileWeapon, + 100, + stackSize: 20, + weenieClassId: wcid); h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor); - h.SplitQuantity.Reset(20u, initialValue: 5u); // partial -- must be ignored + h.SplitQuantity.Reset(20u, initialValue: 5u); h.Controller.HandleDropRelease( h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid)); + + Assert.Equal( + new[] { (PlayerOwnedWeaponGuid, Harness.PlayerGuid, 0u, 5u) }, + h.SplitPuts); + Assert.Equal(PlayerOwnedWeaponGuid, h.SellingList.GetItem(0)!.ItemId); + Assert.Equal( + new[] { "Splitting the Item 60000202 before selling them" }, + h.SystemMessages); + + // SetStackSize completes the split request. CreateObject + placement + // identify the server-assigned split guid and replace the placeholder. + Assert.True(h.Objects.UpdateStackSize(PlayerOwnedWeaponGuid, 15, value: 75)); + MakePlayerOwned( + h, + splitGuid, + ItemType.MissileWeapon, + 25, + stackSize: 5, + weenieClassId: wcid); + Assert.Equal(splitGuid, h.SellingList.GetItem(0)!.ItemId); + h.SellAllButton.OnClick!.Invoke(); (_, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells); - Assert.Equal(new (int Amount, uint ItemGuid)[] { (20, PlayerOwnedWeaponGuid) }, items); + Assert.Equal(new (int Amount, uint ItemGuid)[] { (5, splitGuid) }, items); + } + + [Fact] + public void HandleDropRelease_PartialStackFailure_RemovesTheTemporarySellRow() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty()); + MakePlayerOwned( + h, + PlayerOwnedWeaponGuid, + ItemType.MissileWeapon, + 100, + stackSize: 10, + weenieClassId: 0x2345u); + h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor); + h.SplitQuantity.Reset(10u, initialValue: 2u); + + h.Controller.HandleDropRelease( + h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid)); + Assert.Equal(1, h.SellingList.GetNumUIItems()); + + h.Objects.RejectMove(PlayerOwnedWeaponGuid, weenieError: 0x29u); + + Assert.Equal(0, h.SellingList.GetNumUIItems()); + Assert.Empty(h.Sells); } [Fact] @@ -2309,6 +2434,84 @@ public sealed class VendorUiControllerTests Assert.Empty(h.Sells); } + [Fact] + public void DoubleClickStagedSellingRow_RemovesTheEntryAndReportsRetailsNotice() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty()); + MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100); + h.Controller.HandleDropRelease( + h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid)); + + h.SellingList.GetItem(0)!.DoubleClicked!.Invoke(); + + Assert.Equal(0, h.SellingList.GetNumUIItems()); + Assert.Equal(PlayerOwnedArmorGuid, h.Selection.SelectedObjectId); + Assert.Equal( + new[] { "Removing Item 60000201 from shopping list" }, + h.SystemMessages); + Assert.Empty(h.Sells); + } + + [Fact] + public void DragStagedSellingRow_RemovesItAndPartialSelectionPrintsExactRefusalThenResets() + { + var h = new Harness(); + h.State.Apply( + VendorGuid, + SellProfile((uint)ItemType.MissileWeapon), + Array.Empty()); + MakePlayerOwned( + h, + PlayerOwnedWeaponGuid, + ItemType.MissileWeapon, + 100, + stackSize: 10); + h.Controller.HandleDropRelease( + h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid)); + h.SplitQuantity.Reset(10u, initialValue: 2u); + UiItemSlot staged = h.SellingList.GetItem(0)!; + + h.Controller.OnDragLift( + h.SellingList, + staged, + new ItemDragPayload( + PlayerOwnedWeaponGuid, + ItemDragSource.Inventory, + staged.SlotIndex, + staged)); + + Assert.Equal(0, h.SellingList.GetNumUIItems()); + Assert.Equal( + new[] { "You cannot split items from this panel" }, + h.SystemMessages); + Assert.Equal(10u, h.SplitQuantity.Value); + Assert.Equal(10u, h.SplitQuantity.Maximum); + } + + [Fact] + public void RightClickStagedBuyingAndSellingRows_SelectsAndExaminesBoth() + { + var h = new Harness(); + h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), new[] + { + new VendorShopItem( + ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500), + }); + h.AddButton.OnClick!.Invoke(); + MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100); + h.Controller.HandleDropRelease( + h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid)); + + UiItemSlot buying = h.BuyingList.GetItem(0)!; + buying.OnEvent(new UiEvent(0u, buying, UiEventType.RightClick)); + UiItemSlot selling = h.SellingList.GetItem(0)!; + selling.OnEvent(new UiEvent(0u, selling, UiEventType.RightClick)); + + Assert.Equal(new[] { ArmorItemGuid, PlayerOwnedArmorGuid }, h.Examines); + Assert.Equal(PlayerOwnedArmorGuid, h.Selection.SelectedObjectId); + } + // ══════════════════════════════════════════════════════════════════════ // F10 (Slice 6b/6c review) — unstage on removal/dispossession. // ══════════════════════════════════════════════════════════════════════ diff --git a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs index 44b0144c..f120843e 100644 --- a/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs +++ b/tests/AcDream.App.Tests/UI/RetailUiInteractionFlowTests.cs @@ -297,10 +297,19 @@ public sealed class RetailUiInteractionFlowTests Assert.True(probe.DoubleClickItem(Hauberk, ItemDragSource.Inventory)); Assert.Equal(new[] { (Hauberk, (uint)HauberkMask) }, h.Wields); + var pending = probe.AssertItem( + Hauberk, + equippedLocation: EquipMask.None, + containerId: Player); + Assert.True(pending.Success, pending.Message); + + Assert.True(h.Objects.ApplyConfirmedServerWield( + Hauberk, Player, HauberkMask)); + var state = probe.AssertItem( Hauberk, equippedLocation: HauberkMask, - containerId: Player); + containerId: 0u); Assert.True(state.Success, state.Message); } @@ -337,9 +346,15 @@ public sealed class RetailUiInteractionFlowTests Assert.Equal(EquipMask.MeleeWeapon, h.Objects.Get(Sword)!.CurrentlyEquippedLocation); - h.Objects.MoveItem(Sword, Player, 0, EquipMask.None); + Assert.True(h.Objects.ApplyConfirmedServerMove(Sword, Player, 0u, 0)); Assert.Equal(new[] { (Bow, (uint)EquipMask.MissileWeapon) }, h.Wields); + Assert.Equal(EquipMask.None, + h.Objects.Get(Bow)!.CurrentlyEquippedLocation); + + Assert.True(h.Objects.ApplyConfirmedServerWield( + Bow, Player, EquipMask.MissileWeapon)); + Assert.Equal(EquipMask.MissileWeapon, h.Objects.Get(Bow)!.CurrentlyEquippedLocation); } @@ -453,6 +468,11 @@ public sealed class RetailUiInteractionFlowTests Assert.True(probe.DragItemOutside(Hauberk, 700, 500, ItemDragSource.Inventory)); Assert.Equal(new[] { Hauberk }, h.Drops); + var pending = probe.AssertItem(Hauberk, containerId: Player, slot: 0); + Assert.True(pending.Success, pending.Message); + + Assert.True(h.Objects.ApplyConfirmedServerMove(Hauberk, 0u, 0u, -1)); + var state = probe.AssertItem(Hauberk, containerId: 0u, slot: -1); Assert.True(state.Success, state.Message); } @@ -470,10 +490,19 @@ public sealed class RetailUiInteractionFlowTests Assert.True(probe.DragItemToElement(Hauberk, ChestArmorSlotId, ItemDragSource.Inventory)); Assert.Equal(new[] { (Hauberk, (uint)HauberkMask) }, h.Wields); + var pending = probe.AssertItem( + Hauberk, + equippedLocation: EquipMask.None, + containerId: Player); + Assert.True(pending.Success, pending.Message); + + Assert.True(h.Objects.ApplyConfirmedServerWield( + Hauberk, Player, HauberkMask)); + var state = probe.AssertItem( Hauberk, equippedLocation: HauberkMask, - containerId: Player); + containerId: 0u); Assert.True(state.Success, state.Message); } } diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs index effd8015..41d7f19d 100644 --- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs +++ b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs @@ -6,6 +6,44 @@ namespace AcDream.App.Tests.UI; public class UiRootInputTests { + [Fact] + public void KeypadEnter_DoesNotUseTheRawChatActivationFallback() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var field = new UiField { Width = 100, Height = 20 }; + root.AddChild(field); + root.DefaultTextInput = field; + + root.OnKeyDown((int)Silk.NET.Input.Key.KeypadEnter); + + Assert.Null(root.KeyboardFocus); + } + + [Fact] + public void SemanticChatActivation_SuppressesTheSameNativeEnterTail() + { + var root = new UiRoot { Width = 800, Height = 600 }; + var field = new UiField { Width = 100, Height = 20 }; + int submissions = 0; + field.SetText("hello"); + field.OnSubmit = _ => submissions++; + root.AddChild(field); + root.DefaultTextInput = field; + root.SetKeyboardFocus(field); + root.SuppressPhysicalKeyUntilRelease(Silk.NET.Input.Key.Enter); + + root.OnKeyDown((int)Silk.NET.Input.Key.Enter); + root.OnChar('x'); + + Assert.Equal(0, submissions); + Assert.Equal("hello", field.Text); + Assert.Same(field, root.KeyboardFocus); + + root.OnKeyUp((int)Silk.NET.Input.Key.Enter); + root.OnChar('x'); + Assert.Equal("hellox", field.Text); + } + [Fact] public void UiNineSlicePanel_IsNotAnchorManaged_SoUserMoveResizeSticks() { diff --git a/tests/AcDream.Core.Net.Tests/Messages/ClientCommandRequestsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/ClientCommandRequestsTests.cs index 6142171a..fdfd6d2a 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/ClientCommandRequestsTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/ClientCommandRequestsTests.cs @@ -67,6 +67,7 @@ public sealed class ClientCommandRequestsTests { { ClientCommandRequests.BuildSetAfkMessage, ClientCommandRequests.SetAfkMessageOpcode }, { ClientCommandRequests.BuildEmote, ClientCommandRequests.EmoteOpcode }, + { ClientCommandRequests.BuildSoulEmote, ClientCommandRequests.SoulEmoteOpcode }, { ClientCommandRequests.BuildAddFriend, ClientCommandRequests.AddFriendOpcode }, { ClientCommandRequests.BuildRemoveConsent, ClientCommandRequests.RemoveConsentOpcode }, }; diff --git a/tests/AcDream.Core.Net.Tests/Messages/ServerMessageTests.cs b/tests/AcDream.Core.Net.Tests/Messages/ServerMessageTests.cs index c3c05bf9..866755c6 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/ServerMessageTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/ServerMessageTests.cs @@ -27,6 +27,22 @@ public sealed class ServerMessageTests Assert.Equal(5u, parsed.Value.ChatType); } + [Fact] + public void TryParse_PreservesEmbeddedCommandResponseNewlines() + { + const string text = "@acecommands\n@help\n@teleport"; + byte[] msg = PackString16L(text); + byte[] body = new byte[4 + msg.Length + 4]; + BinaryPrimitives.WriteUInt32LittleEndian(body, ServerMessage.Opcode); + Array.Copy(msg, 0, body, 4, msg.Length); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4 + msg.Length), 0u); + + ServerMessage.Parsed parsed = Assert.IsType( + ServerMessage.TryParse(body)); + + Assert.Equal(text, parsed.Message); + } + [Fact] public void TryParse_WrongOpcode_ReturnsNull() { diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionChatTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionChatTests.cs index c90c06c2..029fc7a0 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionChatTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionChatTests.cs @@ -77,6 +77,18 @@ public sealed class WorldSessionChatTests Assert.Throws(() => session.SendTalk(null!)); } + [Fact] + public void SendSoulEmote_EmitsRetailGameAction() + { + using var session = NewSession(); + byte[]? captured = null; + session.GameActionCapture = body => captured = body; + + session.SendSoulEmote("waves."); + + Assert.Equal(ClientCommandRequests.BuildSoulEmote(1u, "waves."), captured); + } + [Fact] public void SendTeleportToLifestone_EmitsRetailGameAction() { diff --git a/tests/AcDream.Core.Tests/Chat/ChatCommandTargetStateTests.cs b/tests/AcDream.Core.Tests/Chat/ChatCommandTargetStateTests.cs index c8ec32e4..82121666 100644 --- a/tests/AcDream.Core.Tests/Chat/ChatCommandTargetStateTests.cs +++ b/tests/AcDream.Core.Tests/Chat/ChatCommandTargetStateTests.cs @@ -17,6 +17,22 @@ public sealed class ChatCommandTargetStateTests Assert.Equal("Caith", targets.LastOutgoingTellTarget); } + [Fact] + public void TracksIndependentMonarchAndPatronReplyTargetsFromLegacyBroadcasts() + { + var chat = new ChatLog(); + using var targets = new ChatCommandTargetState(chat); + + // 0x0147 does not carry a sender GUID; the incoming sender name is + // nevertheless authoritative for retail's monarch/patron reply keys. + chat.OnChannelBroadcast(0x4000u, "Monarch", "orders"); + chat.OnChannelBroadcast(0x2000u, "Patron", "hello"); + chat.OnChannelBroadcast(0x4000u, "New Monarch", "new orders"); + + Assert.Equal("New Monarch", targets.LastMonarchSender); + Assert.Equal("Patron", targets.LastPatronSender); + } + [Fact] public void ResetSessionForgetsTargetsButPreservesTranscript() { @@ -29,6 +45,8 @@ public sealed class ChatCommandTargetStateTests Assert.Null(targets.LastIncomingTellSender); Assert.Null(targets.LastOutgoingTellTarget); + Assert.Null(targets.LastMonarchSender); + Assert.Null(targets.LastPatronSender); Assert.Equal(2, chat.Count); } diff --git a/tests/AcDream.Core.Tests/Chat/InventoryFailureMessagesTests.cs b/tests/AcDream.Core.Tests/Chat/InventoryFailureMessagesTests.cs index 4a24b9be..46742e82 100644 --- a/tests/AcDream.Core.Tests/Chat/InventoryFailureMessagesTests.cs +++ b/tests/AcDream.Core.Tests/Chat/InventoryFailureMessagesTests.cs @@ -32,6 +32,12 @@ public sealed class InventoryFailureMessagesTests [InlineData( InventoryRequestKind.SplitToContainer, "Arrows", 0x36u, "The Arrows can't be split - action cancelled")] + [InlineData( + InventoryRequestKind.Move, "Sword", 0u, + "The Sword can't be moved")] + [InlineData( + InventoryRequestKind.Wield, "Sword", 0x1Du, + "The Sword can't be wielded - you're too busy")] public void ComposeMatchesServerSaysAttemptFailed( InventoryRequestKind kind, string name, diff --git a/tests/AcDream.Core.Tests/Input/RetailActionIdentityRoundTripTests.cs b/tests/AcDream.Core.Tests/Input/RetailActionIdentityRoundTripTests.cs index 8ae3fe69..c82ca498 100644 --- a/tests/AcDream.Core.Tests/Input/RetailActionIdentityRoundTripTests.cs +++ b/tests/AcDream.Core.Tests/Input/RetailActionIdentityRoundTripTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using AcDream.Content; using AcDream.Core.Input; +using AcDream.Runtime.Gameplay; using AcDream.UI.Abstractions.Input; using DatReaderWriter; using Xunit; @@ -9,84 +10,13 @@ using Xunit; namespace AcDream.Core.Tests.Input; /// -/// Campaign OP slice OP8: pins 's agreement -/// with — for every -/// this slice's table resolves, the UNION of DAT default bindings across every DAT -/// row mapped to that action must equal KeyBindings.RetailDefaults()'s chord -/// set for it. Per the slice contract: "investigate + report any disagreement rather -/// than silently preferring one." Skips cleanly when the installed dats are -/// unavailable (CI), matching every other live-DAT conformance test in this project. -/// -/// -/// Two real, byte-verified disagreements survive after the mechanism fixes -/// (2026-08-11 investigation, updated at the M2 rework — none are bugs in this -/// slice's table; both are PRE-EXISTING -/// gaps/design choices this slice does not touch, listed in -/// with citations). A THIRD -/// disagreement — ten CameraAlternateControls (InputMap 0x6) actions — was RETIRED -/// at the M2 rework: no longer maps InputMap -/// 0x6 to any at all (the aliasing that produced two -/// independent rows fighting over one live target — M2, 2026-08-11 review), so this -/// test never sees a ctx-0x6 row and the ctx-0x5-only union now matches -/// RetailDefaults() exactly for all twelve Camera actions with no allowlist -/// entry needed: -/// -/// -/// MovementWalkMode. The DAT's raw QualifiedControl.Modifier -/// for the Shift-key binding is 0 (the key itself IS Shift — there is no separate -/// "modifier" to report when the primary key and the modifier are the same physical -/// key). RetailDefaults() deliberately encodes Modifiers=Shift anyway — -/// its own comment (K-fix1, 2026-04-26) explains the OS echoes -/// CurrentModifiers=Shift alongside a Shift key-DOWN event, so the chord must -/// carry the flag to match at dispatch time. Not a disagreement to fix; a raw-DAT -/// artifact this slice's reader faithfully reproduces. -/// Quickslot 1-9's Ctrl+N chord (and its SelectQuickSlot_1-9 -/// counterpart). The DAT's own default -/// master map binds Ctrl+1..9 to the SAME action id as bare 1..9 ("Quickslot N" — -/// UseQuickSlot_N), NOT to the separate "Select Quickslot N" action id -/// (SelectQuickSlot_N, DAT action ids 0x1000004E-56) — those carry NO -/// default binding at all in the shipped DAT. RetailDefaults()'s own comment -/// (citing gmToolbarUI::ListenToGlobalMessage @0x004BE4E0) asserts retail's -/// CLIENT reinterprets Ctrl+N contextually as Select — a runtime behavior this raw -/// keymap-default probe cannot see (it reads bound ACTIONS, not the dispatch -/// function's own modifier branching). Both readings are independently retail- -/// sourced; reconciling them needs the decompiled dispatch function, out of scope -/// here. Reported, not silently resolved either way. -/// +/// Campaign KB's installed-DAT contract: all 306 user-bindable ActionMap rows +/// have distinct live identities and their default chord sets match the two +/// retail MasterInputMaps exactly. Missing, aliased, or guessed rows fail here. /// [Trait("Lane", "InstalledDat")] public sealed class RetailActionIdentityRoundTripTests { - /// Actions with a citation-backed, pre-existing reason their DAT-union - /// default set legitimately differs from - /// — see class doc. Every other mapped action must match exactly. - private static readonly HashSet KnownRetailDefaultsDisagreements = new() - { - InputAction.MovementWalkMode, - InputAction.UseQuickSlot_1, - InputAction.UseQuickSlot_2, - InputAction.UseQuickSlot_3, - InputAction.UseQuickSlot_4, - InputAction.UseQuickSlot_5, - InputAction.UseQuickSlot_6, - InputAction.UseQuickSlot_7, - InputAction.UseQuickSlot_8, - InputAction.UseQuickSlot_9, - // Same Use-vs-Select ambiguity as the bare-numeral block above: the DAT's - // own "Select Quickslot N" action ids carry NO default binding at all — - // RetailDefaults()'s Ctrl+N->Select mapping rests on the decompiled - // dispatch function's runtime modifier check, not the raw keymap default. - InputAction.SelectQuickSlot_1, - InputAction.SelectQuickSlot_2, - InputAction.SelectQuickSlot_3, - InputAction.SelectQuickSlot_4, - InputAction.SelectQuickSlot_5, - InputAction.SelectQuickSlot_6, - InputAction.SelectQuickSlot_7, - InputAction.SelectQuickSlot_8, - InputAction.SelectQuickSlot_9, - }; - [Fact] public void MappedActions_DatUnionDefaultBindings_MatchRetailDefaults() { @@ -98,13 +28,44 @@ public sealed class RetailActionIdentityRoundTripTests RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read(source); Assert.NotNull(snapshot); + Assert.Equal(306, snapshot!.Rows.Count); + var unresolvedRows = snapshot.Rows + .Where(row => !RetailActionIdentityTable.TryResolve( + row.InputMapId, + row.ActionId, + out _)) + .Select(row => $"0x{row.InputMapId:X8}/0x{row.ActionId:X8}") + .ToArray(); + Assert.Empty(unresolvedRows); + Assert.Equal(306, RetailActionIdentityTable.Map.Count); + Assert.Equal(306, RetailActionIdentityTable.Map.Values.Distinct().Count()); + Assert.Equal(306, RetailActionIdentityTable.ReverseMap.Count); + + var optionIds = new HashSet(); + foreach (RetailActionMapRow row in snapshot.Rows.Where( + static row => row.InputMapId == 0x10000008u)) + { + Assert.True(RetailActionIdentityTable.TryResolve( + row.InputMapId, + row.ActionId, + out InputAction action)); + Assert.True( + RetailActionIdentityTable.TryGetCharacterOptionId( + action, + out uint optionId), + $"CharacterSettings row 0x{row.ActionId:X8} has no PlayerOption id"); + Assert.True(CharacterOptionTable.TryGet(optionId, out _)); + Assert.True(optionIds.Add(optionId), $"duplicate PlayerOption id 0x{optionId:X2}"); + } + Assert.Equal(48, optionIds.Count); + KeyBindings retailDefaults = KeyBindings.RetailDefaults(); // Aggregate DAT default chords by resolved InputAction — a single action can // be reached by more than one DAT row (e.g. the Camera/CameraAlternate pair). var datChordsByAction = new Dictionary>(); var unresolvedScanCodes = new List(); - foreach (RetailActionMapRow row in snapshot!.Rows) + foreach (RetailActionMapRow row in snapshot.Rows) { if (!RetailActionIdentityTable.TryResolve(row.InputMapId, row.ActionId, out InputAction action)) continue; @@ -125,15 +86,12 @@ public sealed class RetailActionIdentityRoundTripTests } } - Assert.True(datChordsByAction.Count > 100, - $"expected >100 mapped actions, got {datChordsByAction.Count}"); + Assert.Equal(306, datChordsByAction.Count); Assert.Empty(unresolvedScanCodes); var mismatches = new List(); foreach ((InputAction action, HashSet datChords) in datChordsByAction) { - if (KnownRetailDefaultsDisagreements.Contains(action)) continue; - var acdreamChords = retailDefaults.ForAction(action).Select(b => b.Chord).ToHashSet(); if (!datChords.SetEquals(acdreamChords)) { @@ -144,8 +102,7 @@ public sealed class RetailActionIdentityRoundTripTests } Assert.True(mismatches.Count == 0, - $"{mismatches.Count} unexpected DAT-vs-RetailDefaults() disagreements " - + "(not in the documented KnownRetailDefaultsDisagreements allowlist):\n" + $"{mismatches.Count} DAT-vs-RetailDefaults() disagreements:\n" + string.Join("\n", mismatches)); } } diff --git a/tests/AcDream.Core.Tests/Input/RetailActionMapReaderTests.cs b/tests/AcDream.Core.Tests/Input/RetailActionMapReaderTests.cs index 11832831..b04a66d6 100644 --- a/tests/AcDream.Core.Tests/Input/RetailActionMapReaderTests.cs +++ b/tests/AcDream.Core.Tests/Input/RetailActionMapReaderTests.cs @@ -169,6 +169,38 @@ public sealed class RetailActionMapReaderTests Assert.Empty(row.DefaultBindings); } + [Fact] + public void Read_PreservesRetailInputMapConflictPolicy() + { + var actionMap = new ActionMap + { + InputMaps = new Dictionary>(), + ConflictingMaps = new Dictionary + { + [0x10000003u] = new InputsConflictsValue + { + InputMap = 0x10000003u, + ConflictingInputMaps = new List + { + 0x10000003u, + 0x10000002u, + }, + }, + }, + }; + var dats = new FakeDatObjectSource(); + dats.Add(RetailActionMapIds.ActionMapId, actionMap); + + RetailActionMapSnapshot snapshot = Assert.IsType( + RetailActionMapReader.Read(dats)); + + Assert.True(snapshot.InputMapsConflict(0x10000003u, 0x10000003u)); + Assert.True(snapshot.InputMapsConflict(0x10000003u, 0x10000002u)); + Assert.False(snapshot.InputMapsConflict(0x10000003u, 0x10000004u)); + Assert.True(snapshot.InputMapsConflict(0xDEADBEEFu, 0xDEADBEEFu)); + Assert.False(snapshot.InputMapsConflict(0xDEADBEEFu, 0x10000003u)); + } + [Fact] public void RetailInputMapHeaders_HasAllNineteenByteVerifiedEntries() { @@ -245,5 +277,12 @@ public sealed class RetailActionMapReader_LiveDatTests Assert.Equal(2, moveForward.DefaultBindings.Count); Assert.Contains(moveForward.DefaultBindings, c => c.Scan == 0x11u); // DIK_W Assert.Contains(moveForward.DefaultBindings, c => c.Scan == 0xC8u); // DIK_UPARROW + + // The authored combat modes intentionally share the five attack/aim + // keys. Retail's conflict table keeps those mode-local contexts apart; + // Configure Keyboard must not erase one mode while editing another. + Assert.False(snapshot.InputMapsConflict(0x10000003u, 0x10000004u)); + Assert.False(snapshot.InputMapsConflict(0x10000003u, 0x10000005u)); + Assert.False(snapshot.InputMapsConflict(0x10000004u, 0x10000005u)); } } diff --git a/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs b/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs index b3bc83e6..e571448a 100644 --- a/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs +++ b/tests/AcDream.Core.Tests/Items/ExternalContainerStateTests.cs @@ -94,6 +94,40 @@ public sealed class ExternalContainerStateTests Assert.Equal(2, delivered); } + [Fact] + public void OpenedCorpseHistoryMatchesRetailSetAndDeleteLifetime() + { + var state = new ExternalContainerState(); + const uint corpse = 0x70000010u; + + Assert.True(state.RequestOpen(corpse, isCorpse: true)); + Assert.True(state.HasCorpseBeenOpened(corpse)); + Assert.Equal(1, state.OpenedCorpseCount); + + state.ApplyViewContents(corpse); + state.ApplyClose(corpse); + Assert.True(state.HasCorpseBeenOpened(corpse)); + Assert.True(state.SetCorpseDeleted(corpse)); + Assert.False(state.HasCorpseBeenOpened(corpse)); + + state.RequestOpen(corpse, isCorpse: true); + Assert.True(state.Reset()); + Assert.False(state.HasCorpseBeenOpened(corpse)); + Assert.Equal(0, state.OpenedCorpseCount); + } + + [Fact] + public void RepeatedGroundObjectRequestStillRecordsCorpseIdentity() + { + var state = new ExternalContainerState(); + const uint corpse = 0x70000011u; + + Assert.True(state.RequestOpen(corpse)); + Assert.False(state.RequestOpen(corpse, isCorpse: true)); + + Assert.True(state.HasCorpseBeenOpened(corpse)); + } + private static ExternalContainerState Open(uint id) { var state = new ExternalContainerState(); diff --git a/tests/AcDream.Core.Tests/Items/InventoryContainerPlacementPolicyTests.cs b/tests/AcDream.Core.Tests/Items/InventoryContainerPlacementPolicyTests.cs new file mode 100644 index 00000000..d0298f3a --- /dev/null +++ b/tests/AcDream.Core.Tests/Items/InventoryContainerPlacementPolicyTests.cs @@ -0,0 +1,76 @@ +using AcDream.Core.Items; + +namespace AcDream.Core.Tests.Items; + +public sealed class InventoryContainerPlacementPolicyTests +{ + private const uint Player = 0x50000001u; + + [Fact] + public void FullItemCapacityRejectsNewItemButAllowsReorder() + { + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = Player, + Name = "Player", + ItemsCapacity = 1, + ContainersCapacity = 7, + }); + objects.AddOrUpdate(new ClientObject { ObjectId = 2u }); + objects.MoveItem(2u, Player, 0); + objects.AddOrUpdate(new ClientObject { ObjectId = 3u }); + + Assert.Equal( + InventoryContainerPlacementRejection.ItemCapacityFull, + InventoryContainerPlacementPolicy.Evaluate(objects, 3u, Player, Player)); + Assert.Equal( + InventoryContainerPlacementRejection.None, + InventoryContainerPlacementPolicy.Evaluate(objects, 2u, Player, Player)); + } + + [Fact] + public void ContainerCycleAndTradeAreRejected() + { + var objects = new ClientObjectTable(); + objects.AddOrUpdate(new ClientObject + { + ObjectId = 10u, Type = ItemType.Container, ItemsCapacity = 24, + }); + objects.AddOrUpdate(new ClientObject + { + ObjectId = 11u, Type = ItemType.Container, ItemsCapacity = 24, + }); + objects.MoveItem(11u, 10u, 0); + + Assert.Equal( + InventoryContainerPlacementRejection.RecursiveContainment, + InventoryContainerPlacementPolicy.Evaluate(objects, 10u, 11u, Player)); + + objects.Get(10u)!.TradeState = 1; + Assert.Equal( + InventoryContainerPlacementRejection.SourceBeingTraded, + InventoryContainerPlacementPolicy.Evaluate(objects, 10u, Player, Player)); + } + + [Fact] + public void FullMessageMatchesRetailContainerTypeBranches() + { + var player = new ClientObject { ObjectId = Player, Name = "Backpack" }; + var bag = new ClientObject { ObjectId = 2u, Name = "Pack" }; + Assert.Equal( + "Backpack is completely full!", + InventoryContainerPlacementPolicy.ComposeClientLocal( + InventoryContainerPlacementRejection.ItemCapacityFull, + null, + player, + Player)); + Assert.Equal( + "The Pack can fit no more containers!", + InventoryContainerPlacementPolicy.ComposeClientLocal( + InventoryContainerPlacementRejection.ContainerCapacityFull, + null, + bag, + Player)); + } +} diff --git a/tests/AcDream.Core.Tests/Items/ItemInteractionPolicyTests.cs b/tests/AcDream.Core.Tests/Items/ItemInteractionPolicyTests.cs index 228d563f..f8f0a1c8 100644 --- a/tests/AcDream.Core.Tests/Items/ItemInteractionPolicyTests.cs +++ b/tests/AcDream.Core.Tests/Items/ItemInteractionPolicyTests.cs @@ -147,9 +147,53 @@ public sealed class ItemInteractionPolicyTests Assert.Equal(ItemPolicyActionKind.Reject, Assert.Single(ItemInteractionPolicy.DecideUse( Use(direct with { TradeState = 1 })).Actions).Kind); - Assert.Contains("wield", Assert.Single(ItemInteractionPolicy.DecideUse( - Use(direct with { Useability = ItemUseability.Wielded })).Actions).Message, - StringComparison.OrdinalIgnoreCase); + Assert.Equal("You cannot use the item because you are trading it", + Assert.Single(ItemInteractionPolicy.DecideUse( + Use(direct with { TradeState = 1 })).Actions).Message); + Assert.Equal("You must wield the item to use it", + Assert.Single(ItemInteractionPolicy.DecideUse( + Use(direct with { Useability = ItemUseability.Wielded })).Actions).Message); + } + + [Fact] + public void UseObject_targetCompatibilityFailures_useRetailVerbatimMessages() + { + var source = OwnedDirect() with + { + Name = "mana stone", + Useability = 0x00080008u, + TargetType = (uint)ItemType.Misc, + }; + var target = Obj(0x6002) with + { + Name = "armor", + Type = ItemType.Armor, + ContainerId = Player, + OwnedByPlayer = true, + }; + + var missing = ItemInteractionPolicy.DecideUse(Use(source) with + { + UseCurrentSelection = true, + }); + Assert.Equal("Select your target before using the mana stone", + Assert.Single(missing.Actions).Message); + + var incompatible = ItemInteractionPolicy.DecideUse(Use(source) with + { + UseCurrentSelection = true, + SelectedTarget = target, + }); + Assert.Equal("Cannot use the mana stone with the armor", + Assert.Single(incompatible.Actions).Message); + + var traded = ItemInteractionPolicy.DecideUse(Use(source) with + { + UseCurrentSelection = true, + SelectedTarget = target with { TradeState = 1 }, + }); + Assert.Equal("You can't use the mana stone on an item you are trading", + Assert.Single(traded.Actions).Message); } [Fact] @@ -219,7 +263,7 @@ public sealed class ItemInteractionPolicyTests PlayerOnGround = false, }); Assert.False(airborne.ReturnValue); - Assert.Equal(ItemPolicyActionKind.Reject, Assert.Single(airborne.Actions).Kind); + Assert.Equal("You cannot do that in mid air", Assert.Single(airborne.Actions).Message); var split = ItemInteractionPolicy.DecidePlacement(PlaceOnGround(item) with { SplitSize = 4 }); Assert.True(split.ReturnValue); @@ -232,8 +276,7 @@ public sealed class ItemInteractionPolicyTests var alreadyWorld = ItemInteractionPolicy.DecidePlacement( PlaceOnGround(item with { IsIn3DView = true })); Assert.False(alreadyWorld.ReturnValue); - Assert.Contains("cancelled", Assert.Single(alreadyWorld.Actions).Message, - StringComparison.OrdinalIgnoreCase); + Assert.Equal("Move cancelled", Assert.Single(alreadyWorld.Actions).Message); } [Fact] diff --git a/tests/AcDream.Core.Tests/Items/VendorStagingListTests.cs b/tests/AcDream.Core.Tests/Items/VendorStagingListTests.cs index fde7ad5b..45989aee 100644 --- a/tests/AcDream.Core.Tests/Items/VendorStagingListTests.cs +++ b/tests/AcDream.Core.Tests/Items/VendorStagingListTests.cs @@ -204,6 +204,28 @@ public sealed class VendorStagingListTests Assert.False(list.TryGet(ItemB, out _)); } + [Fact] + public void ReplacePreservesTheSplitPlaceholderPositionAndQuantity() + { + var list = new VendorStagingList(); + list.Add(ItemA, 2); + list.Add(ItemB, 9); + const uint splitGuid = 0x60000003u; + int fired = 0; + list.Changed += () => fired++; + + Assert.True(list.Replace(ItemA, splitGuid)); + + Assert.Equal( + new[] + { + new VendorStagingEntry(splitGuid, 2), + new VendorStagingEntry(ItemB, 9), + }, + list.Entries); + Assert.Equal(1, fired); + } + [Fact] public void ClearRemovesEveryEntryAndFiresChangedOnce() { diff --git a/tests/AcDream.Runtime.Tests/Chat/LiveChatCommandRouteTests.cs b/tests/AcDream.Runtime.Tests/Chat/LiveChatCommandRouteTests.cs index 87e0b61c..14ccfb9b 100644 --- a/tests/AcDream.Runtime.Tests/Chat/LiveChatCommandRouteTests.cs +++ b/tests/AcDream.Runtime.Tests/Chat/LiveChatCommandRouteTests.cs @@ -59,4 +59,69 @@ public sealed class LiveChatCommandRouteTests route.Publish(new SendServerCommandCmd("@stale")); Assert.Equal(6, sent.Count); } + + [Fact] + public void Say_ConsumesValidDatPoseAndLeavesUnknownTokenAsSpeech() + { + using var communication = new RuntimeCommunicationState(); + using var character = new RuntimeCharacterState(); + var sent = new List(); + var route = new LiveChatCommandRoute(new LiveChatCommandBindings( + _ => { }, + communication, + communication.Chat, + communication.TurbineChat, + character, + () => 0x50000001u, + text => sent.Add($"talk:{text}"), + (_, _) => { }, + (_, _) => { }, + (_, _, _, _, _, _) => { }, + ResolvePose: command => string.Equals( + command, + "wave", + StringComparison.OrdinalIgnoreCase) + ? new RetailChatPose(0x13000087u, "wave.", "waves.") + : null, + ExecuteMotion: motion => sent.Add($"motion:{motion:X8}"), + SendSoulEmote: text => sent.Add($"soul:{text}"))); + route.Activate(); + + route.Publish(new SendChatCmd( + ChatChannelKind.Say, + null, + "hello *WAVE* there *not-a-pose*")); + + Assert.Equal( + [ + "motion:13000087", + "soul:waves.", + "talk:hello there *not-a-pose*", + ], + sent); + ChatEntry local = Assert.Single(communication.Chat.Snapshot()); + Assert.Equal(ChatKind.SoulEmote, local.Kind); + Assert.Equal("You", local.Sender); + Assert.Equal("wave.", local.Text); + } + + [Fact] + public void Say_ContainingOnlyValidPoseDoesNotSendEmptyTalk() + { + using var communication = new RuntimeCommunicationState(); + using var character = new RuntimeCharacterState(); + var sent = new List(); + var route = new LiveChatCommandRoute(new LiveChatCommandBindings( + _ => { }, communication, communication.Chat, + communication.TurbineChat, character, () => 1u, + text => sent.Add($"talk:{text}"), (_, _) => { }, (_, _) => { }, + (_, _, _, _, _, _) => { }, + ResolvePose: _ => new RetailChatPose(7u, string.Empty, string.Empty), + ExecuteMotion: motion => sent.Add($"motion:{motion}"))); + route.Activate(); + + route.Publish(new SendChatCmd(ChatChannelKind.Say, null, " *wave* ")); + + Assert.Equal(["motion:7"], sent); + } } diff --git a/tests/AcDream.Runtime.Tests/Chat/RetailPublicChatParserTests.cs b/tests/AcDream.Runtime.Tests/Chat/RetailPublicChatParserTests.cs new file mode 100644 index 00000000..2f292275 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/Chat/RetailPublicChatParserTests.cs @@ -0,0 +1,35 @@ +using AcDream.Runtime.Chat; + +namespace AcDream.Runtime.Tests.Chat; + +public sealed class RetailPublicChatParserTests +{ + [Fact] + public void InvalidAndUnmatchedTokensRemainLiteral() + { + string text = RetailPublicChatParser.ExtractPoses( + "*unknown* and *unfinished", + _ => null, + _ => throw new Xunit.Sdk.XunitException("must not execute")); + + Assert.Equal("*unknown* and *unfinished", text); + } + + [Fact] + public void MultipleValidStarAndAngleTokensAreRemovedInOrder() + { + var motions = new List(); + string text = RetailPublicChatParser.ExtractPoses( + "a *one* b c", + command => command switch + { + "one" => new RetailChatPose(1u, "", ""), + "two" => new RetailChatPose(2u, "", ""), + _ => null, + }, + pose => motions.Add(pose.MotionCommand)); + + Assert.Equal("a b c", text); + Assert.Equal([1u, 2u], motions); + } +} diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCombatAttackStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCombatAttackStateTests.cs index 2525ed2c..730f5c46 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCombatAttackStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeCombatAttackStateTests.cs @@ -153,6 +153,7 @@ public sealed class RuntimeCombatAttackStateTests now += 0.5d; controller.ReleaseAttack(); Assert.Single(sent); + Assert.True(controller.RepeatAttackInProgress); controller.HandleCommand(new RuntimeCombatAttackInput( RuntimeCombatAttackCommand.AbortForMovement, @@ -161,6 +162,7 @@ public sealed class RuntimeCombatAttackStateTests Assert.Equal(1, cancels); Assert.Single(sent); + Assert.False(controller.RepeatAttackInProgress); Assert.False(controller.BuildInProgress); Assert.Equal(0f, controller.PowerBarLevel); } diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs index 25681f7d..d15c7231 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeInventoryStateTests.cs @@ -128,6 +128,26 @@ public sealed class RuntimeInventoryStateTests Assert.Empty(inventory.Shortcuts.Items); } + [Fact] + public void RemovingAnObjectRetiresRetailOpenedCorpseHistory() + { + using var entities = new RuntimeEntityObjectLifetime(); + using var inventory = new RuntimeInventoryState(entities); + const uint corpse = 0x70000020u; + inventory.Objects.AddOrUpdate(new ClientObject + { + ObjectId = corpse, + PublicWeenieBitfield = (uint)PublicWeenieFlags.Corpse, + }); + inventory.ExternalContainers.RequestOpen(corpse, isCorpse: true); + Assert.True(inventory.ExternalContainers.HasCorpseBeenOpened(corpse)); + + Assert.True(inventory.Objects.Remove(corpse)); + + Assert.False(inventory.ExternalContainers.HasCorpseBeenOpened(corpse)); + Assert.Equal(0, inventory.CaptureOwnership().OpenedCorpseCount); + } + [Fact] public void DisposalFailureIsReportedAfterTerminalOwnerConvergence() { diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs index 59ca1e5f..10b78993 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerMovementStateTests.cs @@ -6,6 +6,24 @@ namespace AcDream.Runtime.Tests.Gameplay; public sealed class RuntimeLocalPlayerMovementStateTests { + private static PhysicsEngine MakeFlatEngine() + { + var engine = new PhysicsEngine(); + var heights = new byte[81]; + Array.Fill(heights, (byte)50); + var heightTable = new float[256]; + for (int i = 0; i < heightTable.Length; i++) + heightTable[i] = i; + engine.AddLandblock( + 0xA9B4FFFFu, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + return engine; + } + [Fact] public void ViewProjectsTheExactCanonicalControllerAndAutorunOwner() { @@ -59,6 +77,30 @@ public sealed class RuntimeLocalPlayerMovementStateTests Assert.False(movement.View.Snapshot.HasCommandInput); } + [Fact] + public void EscapeCommandsFinishJumpAndStopThroughCanonicalController() + { + var controller = new PlayerMovementController(MakeFlatEngine()); + controller.SeedPlacementForTest( + new Vector3(96f, 96f, 50f), + 0xA9B40001u, + new Vector3(96f, 96f, 50f)); + using var movement = new RuntimeLocalPlayerMovementState + { + Controller = controller, + }; + + controller.Update(0.25f, new MovementInput(Jump: true)); + Assert.True(movement.View.JumpCharge.IsCharging); + Assert.True(movement.Execute(RuntimeMovementCommand.FinishJump)); + Assert.False(movement.View.JumpCharge.IsCharging); + + controller.Update(1f / 60f, new MovementInput(Forward: true)); + Assert.False(movement.View.IsStandingStill); + Assert.True(movement.Execute(RuntimeMovementCommand.StopCompletely)); + Assert.Equal(MotionCommand.Ready, controller.Motion.RawState.ForwardCommand); + } + [Fact] public void CommandInputIsDeduplicatedAndResetWithSessionIntent() { @@ -123,6 +165,65 @@ public sealed class RuntimeLocalPlayerMovementStateTests } } + [Fact] + public void CommandMotionUsesCanonicalControllerAndEmitsOneMovementEdge() + { + const uint afkState = 0x43000118u; + var controller = new PlayerMovementController(new PhysicsEngine()); + using var movement = new RuntimeLocalPlayerMovementState + { + Controller = controller, + }; + + Assert.True(movement.ExecuteMotion(afkState)); + Assert.Equal(afkState, controller.Motion.RawState.ForwardCommand); + + MovementResult first = controller.Update(1f / 60f, default); + MovementResult second = controller.Update(1f / 60f, default); + + Assert.True(first.ShouldSendMovementEvent); + RawMotionState outbound = + LocalPlayerOutboundController.BuildRawMotionState(first); + Assert.Equal(afkState, outbound.ForwardCommand); + Assert.False(second.ShouldSendMovementEvent); + } + + [Fact] + public void OutboundRawOverridePreservesRetailActionAndStamp() + { + const uint cheer = 0x1300004Cu; + var raw = new RawMotionState(); + raw.AddAction( + cheer, + speed: 1f, + actionStamp: 7u, + autonomous: true); + var result = new MovementResult( + default, + default, + 0u, + false, + true, + null, + null, + null, + null, + null, + null, + RawMotionStateOverride: new RawMotionState(raw)); + + RawMotionState firstRaw = + LocalPlayerOutboundController.BuildRawMotionState(result); + RawMotionAction firstAction = Assert.Single(firstRaw.Actions); + + Assert.Equal((ushort)0x004C, firstAction.Command); + Assert.Equal(7, firstAction.Stamp); + Assert.True(firstAction.Autonomous); + + raw.RemoveAction(); + Assert.Single(firstRaw.Actions); + } + [Fact] public void ConcurrentRuntimeInstancesHaveIndependentMovementState() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherCaptureTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherCaptureTests.cs index 57c8615e..ed093e6a 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherCaptureTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherCaptureTests.cs @@ -12,8 +12,8 @@ namespace AcDream.UI.Abstractions.Tests.Input; /// non-modifier chord is reported via the supplied callback and the /// dispatcher does NOT fire normal action events for that chord. Esc /// cancels capture (callback receives a sentinel default chord). -/// Modifier-only key transitions don't complete capture — the user can -/// dial in Shift / Ctrl / Alt before pressing the trigger key. +/// A modifier key is captured on release when used alone, or remains a +/// modifier prefix when another key is pressed while it is held. /// public class InputDispatcherCaptureTests { @@ -93,6 +93,23 @@ public class InputDispatcherCaptureTests Assert.Equal(new KeyChord(Key.A, ModifierMask.Shift | ModifierMask.Ctrl), captured!.Value); } + [Fact] + public void BeginCapture_modifier_released_alone_becomes_bare_primary_key() + { + var (dispatcher, kb, _, _, fired) = Build(); + KeyChord? captured = null; + dispatcher.BeginCapture(chord => captured = chord); + + kb.EmitKeyDown(Key.ShiftLeft, ModifierMask.Shift); + Assert.Null(captured); + kb.EmitKeyUp(Key.ShiftLeft, ModifierMask.Shift); + + Assert.Equal( + new KeyChord(Key.ShiftLeft, ModifierMask.None), + captured); + Assert.Empty(fired); + } + [Fact] public void BeginCapture_completes_with_modifier_state() { @@ -106,6 +123,26 @@ public class InputDispatcherCaptureTests Assert.Equal(new KeyChord(Key.A, ModifierMask.Ctrl), captured!.Value); } + [Fact] + public void BeginCapture_consumes_mouse_button_as_retail_qualified_control() + { + var (dispatcher, _, mouse, bindings, fired) = Build(); + var left = new KeyChord( + InputDispatcher.MouseButtonToKey(MouseButton.Left), + ModifierMask.Ctrl, + Device: 1); + bindings.Add(new Binding(left, InputAction.ToggleInventoryPanel)); + mouse.WantCaptureMouse = true; + + KeyChord? captured = null; + dispatcher.BeginCapture(chord => captured = chord); + mouse.EmitMouseDown(MouseButton.Left, ModifierMask.Ctrl); + + Assert.Equal(left, captured); + Assert.False(dispatcher.IsCapturing); + Assert.Empty(fired); + } + [Fact] public void CancelCapture_invokes_callback_with_default_chord_and_clears_state() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs index 6be58e1e..c3941709 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs @@ -101,6 +101,22 @@ public class InputDispatcherTests fired); } + [Fact] + public void Same_scope_retail_duplicate_chord_fires_every_distinct_action() + { + var (_, kb, _, bindings, fired) = Build(); + var chord = new KeyChord(Key.Number1, ModifierMask.Alt); + bindings.Add(new Binding(chord, InputAction.ToggleFloatingChatWindow1)); + bindings.Add(new Binding(chord, InputAction.UseQuickSlot_10)); + + kb.EmitKeyDown(Key.Number1, ModifierMask.Alt); + + Assert.Equal( + [(InputAction.ToggleFloatingChatWindow1, ActivationType.Press), + (InputAction.UseQuickSlot_10, ActivationType.Press)], + fired); + } + [Fact] public void Changing_combat_scope_releases_hold_resolved_in_previous_scope() { @@ -188,6 +204,30 @@ public class InputDispatcherTests Assert.Empty(fired); // no longer held } + [Fact] + public void RetailBareLeftShiftBinding_NormalizesSilkSelfModifierBit() + { + var kb = new FakeKeyboardSource(); + var mouse = new FakeMouseSource(); + var dispatcher = InputDispatcher.CreateDetached( + kb, + mouse, + KeyBindings.RetailDefaults()); + dispatcher.Attach(); + var fired = new List<(InputAction, ActivationType)>(); + dispatcher.Fired += (action, activation) => fired.Add((action, activation)); + + kb.EmitKeyDown(Key.ShiftLeft, ModifierMask.Shift); + kb.EmitKeyUp(Key.ShiftLeft, ModifierMask.Shift); + + Assert.Contains( + (InputAction.MovementWalkMode, ActivationType.Press), + fired); + Assert.Contains( + (InputAction.MovementWalkMode, ActivationType.Release), + fired); + } + [Fact] public void Hold_callback_scope_change_DoesNotDispatchStaleSnapshotChord() { diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs index 17189277..d50d9e1c 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsJsonTests.cs @@ -104,10 +104,19 @@ public class KeyBindingsJsonTests var path = TempFile(); try { - // User customizes ONE action — replace MovementForward with Q. - var custom = new KeyBindings(); - custom.Add(new(new KeyChord(Key.Q, ModifierMask.None), InputAction.MovementForward)); - custom.SaveToFile(path); + // A pre-v7 partial file customizes ONE action. Missing actions in + // those schemas mean "not stored yet", so they default-merge. + const string legacyJson = """ + { + "version": 6, + "actions": { + "MovementForward": [ + { "key": "Q" } + ] + } + } + """; + File.WriteAllText(path, legacyJson); var loaded = KeyBindings.LoadOrDefault(path); @@ -128,6 +137,32 @@ public class KeyBindingsJsonTests } } + [Fact] + public void Roundtrip_preserves_explicitly_unbound_retail_action() + { + var path = TempFile(); + try + { + KeyBindings defaults = KeyBindings.RetailDefaults(); + var customized = new KeyBindings(); + foreach (Binding binding in defaults.All) + { + if (binding.Action != InputAction.ToggleHelp) + customized.Add(binding); + } + + customized.SaveToFile(path); + KeyBindings loaded = KeyBindings.LoadOrDefault(path); + + Assert.Empty(loaded.ForAction(InputAction.ToggleHelp)); + Assert.NotEmpty(loaded.ForAction(InputAction.ToggleOptionsPanel)); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + [Fact] public void LoadOrDefault_handles_version_zero_legacy_file() { @@ -193,17 +228,16 @@ public class KeyBindingsJsonTests } [Fact] - public void LoadOrDefault_migratesV1CtrlNumberQuickSlotFromUseToSelect() + public void LoadOrDefault_migratesV5CtrlNumberQuickSlotFromSelectBackToRetailUse() { var path = TempFile(); try { const string json = """ { - "version": 1, + "version": 5, "actions": { - "UseQuickSlot_5": [ - { "key": "Number5" }, + "SelectQuickSlot_5": [ { "key": "Number5", "mod": "Ctrl" } ] } @@ -214,9 +248,8 @@ public class KeyBindingsJsonTests var loaded = KeyBindings.LoadOrDefault(path); Assert.Equal(InputAction.UseQuickSlot_5, - loaded.Find(new KeyChord(Key.Number5, ModifierMask.None), ActivationType.Press)?.Action); - Assert.Equal(InputAction.SelectQuickSlot_5, loaded.Find(new KeyChord(Key.Number5, ModifierMask.Ctrl), ActivationType.Press)?.Action); + Assert.Empty(loaded.ForAction(InputAction.SelectQuickSlot_5)); } finally { diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs index 6e616f76..626ba0a9 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs @@ -76,9 +76,11 @@ public class KeyBindingsRetailTests { var b = KeyBindings.RetailDefaults(); var binds = b.ForAction(InputAction.MovementWalkMode).ToList(); - Assert.NotEmpty(binds); - Assert.All(binds, x => Assert.Equal(ActivationType.Hold, x.Activation)); - Assert.Contains(binds, x => x.Chord.Key == Key.ShiftLeft); + Binding binding = Assert.Single(binds); + Assert.Equal(ActivationType.Hold, binding.Activation); + Assert.Equal( + new KeyChord(Key.ShiftLeft, ModifierMask.None), + binding.Chord); } [Fact] @@ -135,14 +137,15 @@ public class KeyBindingsRetailTests } [Fact] - public void QuickSlot_5_bareUsesAndCtrlSelects() + public void QuickSlot_5_BareAndCtrlBothUseRetailAction() { var b = KeyBindings.RetailDefaults(); var bare = b.Find(new KeyChord(Key.Number5, ModifierMask.None), ActivationType.Press); var ctrl = b.Find(new KeyChord(Key.Number5, ModifierMask.Ctrl), ActivationType.Press); Assert.Equal(InputAction.UseQuickSlot_5, bare?.Action); - Assert.Equal(InputAction.SelectQuickSlot_5, ctrl?.Action); + Assert.Equal(InputAction.UseQuickSlot_5, ctrl?.Action); + Assert.Empty(b.ForAction(InputAction.SelectQuickSlot_5)); } [Fact] @@ -216,6 +219,18 @@ public class KeyBindingsRetailTests var binds = b.ForAction(InputAction.CameraActivateAlternateMode).ToList(); Assert.Contains(binds, x => x.Chord == new KeyChord(Key.F2, ModifierMask.None)); Assert.Contains(binds, x => x.Chord == new KeyChord(Key.KeypadDivide, ModifierMask.None)); + Assert.All(binds, x => Assert.Equal(ActivationType.Hold, x.Activation)); + } + + [Theory] + [InlineData(InputAction.CombatAimLow)] + [InlineData(InputAction.CombatAimMedium)] + [InlineData(InputAction.CombatAimHigh)] + public void Missile_aim_actions_use_retail_press_and_release_edges(InputAction action) + { + var binding = Assert.Single(KeyBindings.RetailDefaults().ForAction(action)); + Assert.Equal(ActivationType.Hold, binding.Activation); + Assert.Equal(InputScope.MissileCombat, binding.Scope); } [Fact] diff --git a/tools/dump-keymap/Program.cs b/tools/dump-keymap/Program.cs index fc29d896..77e84911 100644 --- a/tools/dump-keymap/Program.cs +++ b/tools/dump-keymap/Program.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using DatReaderWriter; using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; // Dumps the retail-default keymap (gmDefaultMap @ 0x14000000) from // client_portal.dat. Used for the Phase K control overhaul — extracts @@ -74,8 +75,57 @@ foreach (uint id in new uint[] { 0x14000000u, 0x14000002u }) } } +// The Configure Keyboard contract is wider than the default map: the +// ActionMap contains every user-bindable row, including initially-unbound +// emotes and character-option toggles. Dump it in stable, machine-readable +// order so conformance work never infers the 306-row universe from the much +// smaller set of actions that happen to have default chords. +Console.WriteLine(); +Console.WriteLine("## ActionMap 0x26000000 user-bindable rows"); +var actionMap = dat.Get(0x26000000u); +var actionStrings = dat.Get(0x23000005u); +if (actionMap is null) +{ + Console.WriteLine(" (not found)"); +} +else +{ + Console.WriteLine(" InputMap|Action|Class|LabelHash|Label|TooltipHash|Tooltip"); + foreach ((uint inputMapId, Dictionary actions) in + actionMap.InputMaps.OrderBy(entry => entry.Key)) + { + foreach ((uint actionId, ActionMapValue value) in + actions.OrderBy(entry => entry.Key)) + { + UserBindingData? binding = value.UserBinding; + if (binding is null || binding.ActionClass == 0u) + continue; + + Console.WriteLine( + $" 0x{inputMapId:X8}|0x{actionId:X8}|{binding.ActionClass}|" + + $"0x{binding.ActionName:X8}|{Resolve(binding.ActionName)}|" + + $"0x{binding.ActionDescription:X8}|{Resolve(binding.ActionDescription)}"); + } + } +} + return 0; +string Resolve(uint hash) +{ + if (actionStrings is null + || !actionStrings.Strings.TryGetValue(hash, out var entry) + || entry.Strings.Count == 0) + { + return string.Empty; + } + + return entry.Strings[0].Value + .Replace("\r", "\\r", StringComparison.Ordinal) + .Replace("\n", "\\n", StringComparison.Ordinal) + .Replace("|", "\\|", StringComparison.Ordinal); +} + // ── Key decoding (scan code in high word, device id in low word) ────── static (uint scan, uint dev) SplitKey(uint key) => ((key >> 16) & 0xFFFFu, key & 0xFFFFu); diff --git a/tools/run-release-gate.ps1 b/tools/run-release-gate.ps1 index 333fefd6..8e7194e3 100644 --- a/tools/run-release-gate.ps1 +++ b/tools/run-release-gate.ps1 @@ -3,7 +3,8 @@ Runs the complete portable Release gate with bounded child processes. .DESCRIPTION - Verifies that AcDream.slnx owns every project under src/, tests/, and tools/; + Verifies that AcDream.slnx owns every product project under src/, tests/, + and tools/ (deployment-only ACE server mods are intentionally separate); performs a locked restore; builds that complete supported graph; discovers every test project under tests/ that declares itself through Microsoft.NET.Test.Sdk or IsTestProject; and then runs each test assembly in @@ -199,7 +200,13 @@ function Get-SolutionProjects { foreach ($directoryName in @('src', 'tests', 'tools')) { Get-ChildItem -LiteralPath (Join-Path $repoRoot $directoryName) -Recurse -Filter '*.csproj' -File } - ) + ) | Where-Object { + # tools/ace-mods projects compile against a separately installed ACE + # server and are deployed into that server, not shipped as part of the + # portable acdream product graph. + $relative = [IO.Path]::GetRelativePath($repoRoot, $_.FullName).Replace('\', '/') + -not $relative.StartsWith('tools/ace-mods/', [StringComparison]::OrdinalIgnoreCase) + } $solutionSet = [Collections.Generic.HashSet[string]]::new( [StringComparer]::OrdinalIgnoreCase) foreach ($project in $projects) { From 1d2f2f738f8623ffd051ac97f5a912de5bd88f8e Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 27 Aug 2026 14:30:21 +0200 Subject: [PATCH 82/89] fix #451: stabilize portal seam rendering --- docs/ISSUES.md | 56 ++ .../Rendering/ClipFrameAssembler.cs | 81 +++ .../Rendering/Packs/AtmosphericFrameInputs.cs | 2 +- .../Packs/AtmosphericPostProcessGraph.cs | 5 +- .../DeclaredFullscreenRenderPackGraph.cs | 3 +- .../Rendering/ParticleRenderer.Rhi.cs | 58 ++- src/AcDream.App/Rendering/ParticleRenderer.cs | 100 ++-- .../Rendering/PortalVisibilityBuilder.cs | 91 +++- .../Rendering/RetailPViewPassExecutor.cs | 125 ++++- .../Rendering/RetailPViewRenderer.cs | 483 ++++++++++++++---- .../Scene/CurrentRenderSceneOracle.cs | 1 + .../Rendering/Scene/RenderFrameProduct.cs | 1 + .../Scene/RenderScenePViewFrameProduct.cs | 112 +++- .../Rendering/Shaders/particle.vert | 22 + .../Rendering/Shaders/particle_mesh.vert | 22 + .../Rendering/Shaders/spv/particle.vert.spv | Bin 1844 -> 2832 bytes .../Shaders/spv/particle_mesh.vert.spv | Bin 1384 -> 2388 bytes .../Shaders/spv/shaders.manifest.json | 4 +- .../Rendering/WorldRenderFrameBuilder.cs | 15 + .../Rendering/WorldScenePassExecutor.cs | 15 +- .../Rendering/ClipFrameAssemblerTests.cs | 33 ++ .../Gpu/Vk/VulkanShaderManifestTests.cs | 7 +- .../ParticleBindlessInstanceTests.cs | 8 +- .../RenderScenePViewFrameProductTests.cs | 246 ++++++++- .../Rendering/RetailPViewPassExecutorTests.cs | 156 +++++- .../Rendering/RhiVertexLayoutStrideTests.cs | 5 +- .../Rendering/SanctuaryPortalSeamTests.cs | 226 ++++++++ .../Rendering/WorldRenderFrameBuilderTests.cs | 6 + .../Rendering/WorldSceneRendererTests.cs | 6 +- 29 files changed, 1650 insertions(+), 239 deletions(-) create mode 100644 tests/AcDream.App.Tests/Rendering/SanctuaryPortalSeamTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 199cd8da..9c9e7c76 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,48 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #451 — Sanctuary cathedral portal seam leaks exterior world and particles + +**Status:** DONE — OWNER-ACCEPTED 2026-08-27 ("Looks good! Gate pass now!"). +**Component:** Vulkan PView / nested building look-ins / landscape alpha ordering. + +At the open-air Sanctuary cathedral seam between `0xF4180104` and +`0xF4180106`, small player or chase-camera movements could make cathedral +halves, their shadows, or floor textures flap; expose the world background, +trees, a nearby building, and waterfall/steam particles through opaque +geometry; or clip the local player in half. Camera zoom alone reproduced the +failure. The final particle repro was +`0xF4180104 [31.111177 57.648911 169.804993]`. + +This was a renderer contract failure, not bad Sanctuary data. The correction: + +- classifies exterior building seeds with retail's `F_EPSILON` instead of the + ordinary 1 cm EnvCell traversal tolerance and retains the exact accepted + `CBldPortal` aperture; +- appends every nested look-in cell's own `portal_view` to the frame clip + buffer, punches only the accepted seed portal, and clips shells plus static + and particle alpha to that view; +- pairs each look-in with only its own exterior building shell and reproduces + retail's per-building alpha barriers; +- keeps dynamic objects whole after the CPU PortalList sphere test, matching + retail `DrawMesh` and preventing the player-slicing regression; +- submits attached and ownerless exterior particles inside `LScape::draw` for + every PView root, eliminating the late post-world replay that let waterfall + alpha repaint already-drawn cathedral cells; and +- treats authored `SeenOutside` open-air cells as atmospheric outdoor cells so + sky/shadow activation no longer flips merely because the camera acquired an + EnvCell root. + +The fix is renderer-wide: no Sanctuary coordinate or asset special case is +present in production. Installed-DAT regressions retain the reported camera +handoff, lateral transition, and exact steam-seam views. The temporary live +emitter/shell trace was removed at closeout. + +**Acceptance:** owner swept both cells, moved across the seam, and zoomed the +camera through the former trigger positions. Cathedral/world flapping, shadow +toggle, player clipping, exterior geometry, and waterfall/particle bleed were +absent in the accepted build. + ## #450 — Fast character re-entry after logout can remain in portal space at `lb 0/0` **Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate. @@ -18959,6 +19001,13 @@ DrawDynamicsParticles only sees dynamics-last cone survivors. **Gate:** stand inside, look out the doorway at the town portal — the swirl renders through the door. +**2026-08-27 ordering correction (#451):** the old "once per frame after the +look-ins" placement was sufficient for this gate but did not preserve the +installed `outside_view` and could repaint an already-drawn open-air building. +Ownerless emitters now submit once per outside-view slice with that slice's +clip slot. When building look-ins exist they enter the pre-building alpha +barrier; otherwise they drain at the end of `LScape::draw`. + --- ## #132 — Candle flame disappears when the through-opening background is behind it @@ -18999,6 +19048,13 @@ against interiors). The owner-id filter carries over; cell-pass and dynamics-pass emitters keep their own passes (owners never in the outdoor-static set → no double-draw). +**2026-08-27 correction (#451):** the post-frame placement fixed this narrow +flame-overpaint case but was too late for nested open-air cathedral cells: an +exterior waterfall could repaint their completed opaque floor. Outdoor-static +and ownerless particles now submit inside `LScape::draw` under the exact +outside-view clip slot, with retail's pre-building/per-building alpha barriers; +the post-world PView replay is deleted. + **Gate:** both sides — indoors with the opening behind the candle, and outdoors at the angle that previously erased it. diff --git a/src/AcDream.App/Rendering/ClipFrameAssembler.cs b/src/AcDream.App/Rendering/ClipFrameAssembler.cs index 38b72df4..c7b50def 100644 --- a/src/AcDream.App/Rendering/ClipFrameAssembler.cs +++ b/src/AcDream.App/Rendering/ClipFrameAssembler.cs @@ -39,6 +39,13 @@ public enum TerrainClipMode /// public readonly record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[] Planes); +/// +/// Identifies one cell inside one nested building look-in. The same EnvCell can +/// be reached by more than one building PView, so a cell id alone is not a +/// sufficient routing key. +/// +public readonly record struct LookInClipCell(int FrameIndex, uint CellId); + /// /// Result of : populated clip buffers /// plus routing data consumed by the render orchestration. @@ -57,6 +64,12 @@ public sealed class ClipFrameAssembly /// Full retail portal_view slices per visible cell. public Dictionary CellIdToViewSlices { get; } = new(); + /// First drawable slice slot per nested look-in cell. + public Dictionary LookInCellToSlot { get; } = new(); + + /// All retail portal_view slices per nested look-in cell. + public Dictionary LookInCellToViewSlices { get; } = new(); + /// Full retail outside_view slices. public ClipViewSlice[] OutsideViewSlices { get; private set; } = System.Array.Empty(); @@ -93,6 +106,8 @@ public sealed class ClipFrameAssembly Frame = frame; foreach (ClipViewSlice[] slices in CellIdToViewSlices.Values) ReturnSlices(slices); + foreach (ClipViewSlice[] slices in LookInCellToViewSlices.Values) + ReturnSlices(slices); foreach (int[] slots in CellIdToViewSlots.Values) ReturnSlots(slots); if (OutsideViewSlices.Length != 0) @@ -101,6 +116,8 @@ public sealed class ClipFrameAssembly CellIdToSlot.Clear(); CellIdToViewSlots.Clear(); CellIdToViewSlices.Clear(); + LookInCellToSlot.Clear(); + LookInCellToViewSlices.Clear(); PerCellPlaneCounts.Clear(); OutsideViewSlices = System.Array.Empty(); SliceScratch.Clear(); @@ -363,6 +380,70 @@ public static class ClipFrameAssembler return assembly; } + /// + /// Appends the cell views used by nested DrawBuilding -> DrawPortal + /// PViews to the already assembled frame. Retail installs each nested + /// cell's own portal_view before drawing its shell and object list; + /// these slots must therefore be published with the main frame before the + /// first draw is recorded. + /// + public static void AppendLookInFrames( + ClipFrame frame, + IReadOnlyList lookInFrames, + ClipFrameAssembly assembly) + { + System.ArgumentNullException.ThrowIfNull(frame); + System.ArgumentNullException.ThrowIfNull(lookInFrames); + System.ArgumentNullException.ThrowIfNull(assembly); + if (!ReferenceEquals(frame, assembly.Frame)) + throw new System.ArgumentException( + "The look-in slots must be appended to the assembly's clip frame.", + nameof(frame)); + + for (int frameIndex = 0; frameIndex < lookInFrames.Count; frameIndex++) + { + PortalVisibilityFrame lookIn = lookInFrames[frameIndex]; + foreach (uint cellId in lookIn.OrderedVisibleCells) + { + if (!lookIn.CellViews.TryGetValue(cellId, out CellView? view)) + continue; + + List slices = assembly.SliceScratch; + slices.Clear(); + foreach (ViewPolygon poly in view.Polygons) + { + ClipPlaneSet cps = ClipPlaneSet.From(poly); + if (cps.IsNothingVisible) + continue; + + int slot; + Vector4[] planes; + if (cps.Count > 0) + { + planes = cps.PlaneArray; + slot = frame.AppendSlot(planes); + } + else + { + planes = System.Array.Empty(); + slot = 0; + assembly.ScissorFallbacks++; + } + + slices.Add(new ClipViewSlice(slot, AabbOf(poly), planes)); + } + + if (slices.Count == 0) + continue; + + ClipViewSlice[] packed = assembly.CopySlices(slices); + var key = new LookInClipCell(frameIndex, cellId); + assembly.LookInCellToViewSlices.Add(key, packed); + assembly.LookInCellToSlot.Add(key, packed[0].Slot); + } + } + } + private static Vector4 AabbOf(ViewPolygon poly) => new(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY); diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericFrameInputs.cs b/src/AcDream.App/Rendering/Packs/AtmosphericFrameInputs.cs index cb59ea3e..ab9ec407 100644 --- a/src/AcDream.App/Rendering/Packs/AtmosphericFrameInputs.cs +++ b/src/AcDream.App/Rendering/Packs/AtmosphericFrameInputs.cs @@ -99,7 +99,7 @@ internal sealed class AtmosphericFrameInputState : IAtmosphericWorldFrameSink _host.DeltaSeconds, _host.ViewportWidth, _host.ViewportHeight, - IsOutdoor: world.Roots.RenderSky && !world.Roots.CameraInsideCell); + IsOutdoor: world.Roots.IsAtmosphericallyOutdoor); _published = true; } diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs index 95f7ee1d..2117d648 100644 --- a/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs +++ b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs @@ -431,8 +431,7 @@ internal sealed class AtmosphericPostProcessGraph : var environment = new DirectionalShadowEnvironmentInput( PackEnabled: true, PortalOrLoginCoverVisible: foundation.PortalViewportVisible, - PlayerInsideCell: world.Roots.PlayerInsideCell - || world.Roots.CameraInsideCell, + PlayerInsideCell: world.Roots.PlayerOrCameraInsideEnclosedCell, source, foundation.Atmosphere, ActiveDayGroupMultiplier: Math.Clamp( @@ -449,7 +448,7 @@ internal sealed class AtmosphericPostProcessGraph : // or deactivation can never leave a stale exclusion set applied to // the dispatcher (see FoliageWindExclusions's doc comment). worldMeshes.FoliageWindExclusions = _foliageWindExclusions; - bool isOutdoor = world.Roots.RenderSky && !world.Roots.CameraInsideCell; + bool isOutdoor = world.Roots.IsAtmosphericallyOutdoor; AtmosphericFrameBufferBinding shadowAtmosphericFrame = BuildShadowAtmosphericFrameBinding(frame, foundation.Atmosphere.Kind, isOutdoor); var input = new DirectionalSunShadowRenderInput( diff --git a/src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs b/src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs index 0bf5fafc..e883456c 100644 --- a/src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs +++ b/src/AcDream.App/Rendering/Packs/DeclaredFullscreenRenderPackGraph.cs @@ -210,8 +210,7 @@ internal class DeclaredFullscreenRenderPackGraph : var environment = new DirectionalShadowEnvironmentInput( PackEnabled: true, PortalOrLoginCoverVisible: foundation.PortalViewportVisible, - PlayerInsideCell: world.Roots.PlayerInsideCell - || world.Roots.CameraInsideCell, + PlayerInsideCell: world.Roots.PlayerOrCameraInsideEnclosedCell, source, foundation.Atmosphere, ActiveDayGroupMultiplier: Math.Clamp( diff --git a/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs b/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs index a1ff118e..cd68e206 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.Rhi.cs @@ -71,10 +71,8 @@ public sealed unsafe partial class ParticleRenderer private const uint QuadStrideBytes = 4 * sizeof(float); - /// Floats per mesh-particle instance: a mat4 plus an RGBA colour. - internal const int MeshInstanceFloats = 20; - - private const uint MeshInstanceStrideBytes = MeshInstanceFloats * sizeof(float); + private static readonly uint MeshInstanceStrideBytes = + (uint)sizeof(MeshParticleGpuInstance); /// /// The billboard layout: the shared unit quad at vertex rate, and one @@ -103,7 +101,8 @@ public sealed unsafe partial class ParticleRenderer // Location 6 is `in uint aTextureIndex` — an INTEGER shader input, // so R8G8B8A8's normalized cousin would be wrong in kind. It is one // 32-bit unsigned value; Float1 would reinterpret its bits. - new GpuVertexAttribute(6, GpuVertexFormat.UInt1, 64, Binding: 1))); + new GpuVertexAttribute(6, GpuVertexFormat.UInt1, 64, Binding: 1), + new GpuVertexAttribute(7, GpuVertexFormat.UInt1, 68, Binding: 1))); /// /// The mesh-particle layout: the shared world-mesh vertex at vertex rate, @@ -126,7 +125,8 @@ public sealed unsafe partial class ParticleRenderer new GpuVertexAttribute(4, GpuVertexFormat.Float4, 16, Binding: 1), new GpuVertexAttribute(5, GpuVertexFormat.Float4, 32, Binding: 1), new GpuVertexAttribute(6, GpuVertexFormat.Float4, 48, Binding: 1), - new GpuVertexAttribute(7, GpuVertexFormat.Float4, 64, Binding: 1))); + new GpuVertexAttribute(7, GpuVertexFormat.Float4, 64, Binding: 1), + new GpuVertexAttribute(8, GpuVertexFormat.UInt1, 80, Binding: 1))); /// /// The RHI arm's constructor. No GL context, no Shader, no @@ -340,22 +340,22 @@ public sealed unsafe partial class ParticleRenderer while (submission.Kind == ParticleSubmissionKind.Mesh && _meshDrawListScratch[submission.DrawIndex].Key == meshKey); - int neededFloats = _meshRunScratch.Count * MeshInstanceFloats; - if (_meshInstanceScratch.Length < neededFloats) - _meshInstanceScratch = new float[neededFloats + 256 * MeshInstanceFloats]; + int neededInstances = _meshRunScratch.Count; + if (_meshInstanceScratch.Length < neededInstances) + _meshInstanceScratch = new MeshParticleGpuInstance[neededInstances + 256]; for (int instance = 0; instance < _meshRunScratch.Count; instance++) { WriteMeshGpuInstance( - _meshInstanceScratch, - instance * MeshInstanceFloats, + ref _meshInstanceScratch[instance], _meshRunScratch[instance]); } - GpuRingAllocation instances = WriteVertexRing( + GpuRingAllocation instances = WriteVertexRing( frame, - _meshInstanceScratch.AsSpan(0, neededFloats)); + _meshInstanceScratch.AsSpan(0, neededInstances)); DrawMeshBatchRhi( encoder, + frame, global, batch, viewProjection, @@ -384,7 +384,13 @@ public sealed unsafe partial class ParticleRenderer GpuRingAllocation ring = WriteVertexRing( frame, _instanceScratch.AsSpan(0, instances.Count)); - BindBillboardPipeline(encoder, viewProjection, additive, ring.Buffer, ring.OffsetBytes); + BindBillboardPipeline( + encoder, + frame, + viewProjection, + additive, + ring.Buffer, + ring.OffsetBytes); encoder.DrawIndexed( (uint)QuadIndices.Length, (uint)instances.Count, @@ -401,6 +407,7 @@ public sealed unsafe partial class ParticleRenderer /// private void BindBillboardPipeline( IGpuPassEncoder encoder, + IGpuFrame frame, Matrix4x4 viewProjection, bool additive, IGpuBuffer instanceBuffer, @@ -426,10 +433,15 @@ public sealed unsafe partial class ParticleRenderer encoder.BindVertexBuffer(0, _quadVertexBuffer!, 0); encoder.BindVertexBuffer(1, instanceBuffer, instanceOffsetBytes); encoder.BindIndexBuffer(_quadIndexBuffer!, 0, GpuIndexType.UInt32); + WorldFrameSectionBinding.BindClipRegions( + encoder, + _scope!.Sections, + frame); } private void DrawMeshBatchRhi( IGpuPassEncoder encoder, + IGpuFrame frame, GlobalMeshBuffer global, ObjectRenderBatch batch, Matrix4x4 viewProjection, @@ -472,6 +484,10 @@ public sealed unsafe partial class ParticleRenderer "The shared mesh arena has no index store."), 0, GpuIndexType.UInt16); + WorldFrameSectionBinding.BindClipRegions( + encoder, + _scope!.Sections, + frame); encoder.DrawIndexed( (uint)batch.IndexCount, instanceCount, @@ -520,9 +536,8 @@ public sealed unsafe partial class ParticleRenderer Array.Resize(ref _preparedInstanceOffsets, count + 256); if (_instanceScratch.Length < count) Array.Resize(ref _instanceScratch, count + 256); - int neededMeshFloats = count * MeshInstanceFloats; - if (_meshInstanceScratch.Length < neededMeshFloats) - _meshInstanceScratch = new float[neededMeshFloats + 256 * MeshInstanceFloats]; + if (_meshInstanceScratch.Length < count) + _meshInstanceScratch = new MeshParticleGpuInstance[count + 256]; int billboardCount = 0; int meshCount = 0; @@ -541,8 +556,7 @@ public sealed unsafe partial class ParticleRenderer { _preparedInstanceOffsets[i] = (uint)meshCount; WriteMeshGpuInstance( - _meshInstanceScratch, - meshCount++ * MeshInstanceFloats, + ref _meshInstanceScratch[meshCount++], deferred.Mesh.Instance); } } @@ -553,9 +567,9 @@ public sealed unsafe partial class ParticleRenderer _instanceScratch.AsSpan(0, billboardCount))) : default; _preparedMeshInstances = meshCount > 0 - ? SectionOf(WriteVertexRing( + ? SectionOf(WriteVertexRing( frame, - _meshInstanceScratch.AsSpan(0, meshCount * MeshInstanceFloats))) + _meshInstanceScratch.AsSpan(0, meshCount))) : default; _preparedAlphaCount = count; } @@ -591,6 +605,7 @@ public sealed unsafe partial class ParticleRenderer { BindBillboardPipeline( encoder, + RequireRhiFrame(), viewProjection, key.Additive, billboards, @@ -632,6 +647,7 @@ public sealed unsafe partial class ParticleRenderer { DrawMeshBatchRhi( encoder, + RequireRhiFrame(), global, batch, meshViewProjection, diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index 63036896..6a963121 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -47,6 +47,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable public readonly uint ColorArgb; public readonly AcDream.App.Rendering.Gpu.GpuTextureSlot TextureSlot; public readonly float DistanceSq; + public readonly uint ClipSlot; public ParticleInstance( Vector3 position, @@ -54,7 +55,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable Vector3 axisY, uint colorArgb, AcDream.App.Rendering.Gpu.GpuTextureSlot textureSlot, - float distanceSq) + float distanceSq, + uint clipSlot) { Position = position; AxisX = axisX; @@ -62,6 +64,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable ColorArgb = colorArgb; TextureSlot = textureSlot; DistanceSq = distanceSq; + ClipSlot = clipSlot; } } @@ -80,6 +83,16 @@ public sealed unsafe partial class ParticleRenderer : IDisposable public Vector4 AxisY; public Vector4 Color; public uint TextureIndex; + public uint ClipSlot; + } + + /// Vertex-instance ABI shared with particle_mesh.vert. + [StructLayout(LayoutKind.Sequential)] + internal struct MeshParticleGpuInstance + { + public Matrix4x4 Model; + public Vector4 Color; + public uint ClipSlot; } private readonly struct MeshParticleInstance @@ -87,12 +100,18 @@ public sealed unsafe partial class ParticleRenderer : IDisposable public readonly Matrix4x4 Model; public readonly uint ColorArgb; public readonly float DistanceSq; + public readonly uint ClipSlot; - public MeshParticleInstance(Matrix4x4 model, uint colorArgb, float distanceSq) + public MeshParticleInstance( + Matrix4x4 model, + uint colorArgb, + float distanceSq, + uint clipSlot) { Model = model; ColorArgb = colorArgb; DistanceSq = distanceSq; + ClipSlot = clipSlot; } } @@ -124,7 +143,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable internal (int SetCount, long CapacityBytes) DynamicBufferDiagnostics => (0, 0); private BillboardGpuInstance[] _instanceScratch = new BillboardGpuInstance[256]; - private float[] _meshInstanceScratch = new float[256 * 20]; + private MeshParticleGpuInstance[] _meshInstanceScratch = new MeshParticleGpuInstance[256]; // MP-Alloc (2026-07-05): Draw() is called up to ~11 times per frame // (sky pre/post, scene, per-visible-cell, dynamics, unattached passes), @@ -203,7 +222,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable cameraRight, cameraUp, emitterFilter, - scopedEmitters: null); + scopedEmitters: null, + clipSlot: 0); FinishDraw(camera, renderPass); } @@ -213,7 +233,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable ParticleRenderPass renderPass, IReadOnlySet attachedOwnerIds, bool includeUnattached = false, - IReadOnlySet? excludedAttachedOwnerIds = null) + IReadOnlySet? excludedAttachedOwnerIds = null, + uint clipSlot = 0) { if (camera is null) return; @@ -233,7 +254,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable cameraRight, cameraUp, emitterFilter: null, - _scopedEmitterScratch); + _scopedEmitterScratch, + clipSlot); FinishDraw(camera, renderPass); } @@ -332,7 +354,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable Vector3 cameraRight, Vector3 cameraUp, Func? emitterFilter, - IReadOnlyList? scopedEmitters) + IReadOnlyList? scopedEmitters, + uint clipSlot) { var draws = _drawListScratch; draws.Clear(); @@ -348,6 +371,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable cameraWorldPos, cameraRight, cameraUp, + clipSlot, ref sequence); } return; @@ -356,7 +380,13 @@ public sealed unsafe partial class ParticleRenderer : IDisposable foreach (RuntimeParticleEmitter emitter in _particles.EnumerateRenderableEmitters(renderPass)) { if (emitterFilter is null || emitterFilter(emitter)) - AppendEmitterDraws(emitter, cameraWorldPos, cameraRight, cameraUp, ref sequence); + AppendEmitterDraws( + emitter, + cameraWorldPos, + cameraRight, + cameraUp, + clipSlot, + ref sequence); } } @@ -365,6 +395,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable Vector3 cameraWorldPos, Vector3 cameraRight, Vector3 cameraUp, + uint clipSlot, ref int sequence) { List draws = _drawListScratch; @@ -385,7 +416,13 @@ public sealed unsafe partial class ParticleRenderer : IDisposable uint gfxObjId = em.Desc.HwGfxObjId != 0 ? em.Desc.HwGfxObjId : em.Desc.GfxObjId; if (gfxObjId != 0 && ResolveGeometryKind(gfxObjId) == RetailParticleGeometryKind.FullMesh - && TryAppendMeshDraws(em, p, gfxObjId, cameraWorldPos, ref sequence)) + && TryAppendMeshDraws( + em, + p, + gfxObjId, + cameraWorldPos, + clipSlot, + ref sequence)) { continue; } @@ -483,7 +520,8 @@ public sealed unsafe partial class ParticleRenderer : IDisposable axisY, p.ColorArgb, gfxInfo.TextureSlot, - distSq))); + distSq, + clipSlot))); _submissionScratch.Add(new ParticleSubmission( ParticleSubmissionKind.Billboard, drawIndex, @@ -497,6 +535,7 @@ public sealed unsafe partial class ParticleRenderer : IDisposable Particle particle, uint gfxObjId, Vector3 cameraWorldPosition, + uint clipSlot, ref int sequence) { if (_meshAdapter is null || !MeshParticlesAvailable) @@ -520,7 +559,11 @@ public sealed unsafe partial class ParticleRenderer : IDisposable model, cameraWorldPosition); float distanceSq = viewerDistance * viewerDistance; - var instance = new MeshParticleInstance(model, particle.ColorArgb, distanceSq); + var instance = new MeshParticleInstance( + model, + particle.ColorArgb, + distanceSq, + clipSlot); for (int batchIndex = 0; batchIndex < renderData.Batches.Count; batchIndex++) { @@ -581,35 +624,24 @@ public sealed unsafe partial class ParticleRenderer : IDisposable TextureIndex = particle.TextureSlot.IsAssigned ? particle.TextureSlot.Index : NoTextureSlot, + ClipSlot = particle.ClipSlot, }; } private static void WriteMeshGpuInstance( - float[] destination, - int offset, + ref MeshParticleGpuInstance destination, MeshParticleInstance instance) { - Matrix4x4 model = instance.Model; - destination[offset + 0] = model.M11; - destination[offset + 1] = model.M12; - destination[offset + 2] = model.M13; - destination[offset + 3] = model.M14; - destination[offset + 4] = model.M21; - destination[offset + 5] = model.M22; - destination[offset + 6] = model.M23; - destination[offset + 7] = model.M24; - destination[offset + 8] = model.M31; - destination[offset + 9] = model.M32; - destination[offset + 10] = model.M33; - destination[offset + 11] = model.M34; - destination[offset + 12] = model.M41; - destination[offset + 13] = model.M42; - destination[offset + 14] = model.M43; - destination[offset + 15] = model.M44; - destination[offset + 16] = ((instance.ColorArgb >> 16) & 0xFF) / 255f; - destination[offset + 17] = ((instance.ColorArgb >> 8) & 0xFF) / 255f; - destination[offset + 18] = (instance.ColorArgb & 0xFF) / 255f; - destination[offset + 19] = ((instance.ColorArgb >> 24) & 0xFF) / 255f; + destination = new MeshParticleGpuInstance + { + Model = instance.Model, + Color = new Vector4( + ((instance.ColorArgb >> 16) & 0xFF) / 255f, + ((instance.ColorArgb >> 8) & 0xFF) / 255f, + (instance.ColorArgb & 0xFF) / 255f, + ((instance.ColorArgb >> 24) & 0xFF) / 255f), + ClipSlot = instance.ClipSlot, + }; } private TranslucencyKind ResolveMeshBlend(ObjectRenderBatch batch) diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index a9164297..c0ef5f89 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -26,11 +26,21 @@ public sealed class PortalVisibilityFrame private int _processedViewCountsUnderusedFrames; private int _orderedVisibleCellsUnderusedFrames; private int _todoUnderusedFrames; + private int _exteriorSeedPortalsUnderusedFrames; internal PortalPolygonVertexStore PolygonVertices => _polygonVertices; internal int PolygonVertexAllocationCount => _polygonVertices.AllocationCount; internal int RetainedPolygonVertexArrayCount => _polygonVertices.RetainedArrayCount; + // Interior-root look-ins are constructed one building at a time. Keep + // the source identity on the retained frame so the landscape pass can + // pair retail's portal-only traversal with that building's own exterior + // shell instead of repainting every nearby shell after every look-in. + // Building ids are publication-local, so the landblock is part of the + // identity. An unstamped building uses its seed cell id as the key. + internal uint SourceBuildingKey { get; set; } + internal uint SourceBuildingLandblockId { get; set; } + /// Screen region (NDC) where outdoor terrain/scenery may draw — exit portals /// recursively clipped to their portal chain. The cellar-flap fix. public CellView OutsideView { get; private set; } = new(); @@ -49,6 +59,15 @@ public sealed class PortalVisibilityFrame /// neighbour cell id that left the camera building's cell set (wire-in #3 / Step 5). public Dictionary CrossBuildingViews { get; } = new(); + /// + /// Exact outside-facing building portals that successfully seeded this + /// exterior construction, together with the clipped view region produced + /// by that portal. Retail's DrawBuilding pass punches these CBldPortal + /// apertures themselves; it does not infer them later from every exit on a + /// cell reached by the resulting flood. + /// + public List ExteriorSeedPortals { get; } = new(); + // Build scratch belongs to the frame so a caller that reuses a frame also reuses the large // hash tables and the 128-entry convergence trace. This is especially important outdoors, // where retail runs one small ConstructView flood per nearby building every frame. @@ -114,6 +133,7 @@ public sealed class PortalVisibilityFrame int processedViewCount = ProcessedViewCountsScratch.Count; int orderedVisibleCellCount = OrderedVisibleCells.Count; int todoCount = TodoScratch.Count; + int exteriorSeedPortalCount = ExteriorSeedPortals.Count; if (OutsideView.IsRetainable) OutsideView.Reset(); @@ -121,9 +141,14 @@ public sealed class PortalVisibilityFrame OutsideView = new CellView(); ReturnCellViews(CellViews); ReturnCellViews(CrossBuildingViews); + for (int index = 0; index < ExteriorSeedPortals.Count; index++) + ReturnCellView(ExteriorSeedPortals[index].View); CellViews.Clear(); OrderedVisibleCells.Clear(); CrossBuildingViews.Clear(); + ExteriorSeedPortals.Clear(); + SourceBuildingKey = 0; + SourceBuildingLandblockId = 0; QueuedScratch.Clear(); DrawListedScratch.Clear(); ProcessedViewCountsScratch.Clear(); @@ -152,6 +177,10 @@ public sealed class PortalVisibilityFrame orderedVisibleCellCount, ref _orderedVisibleCellsUnderusedFrames); TrimIfCold(TodoScratch, todoCount, ref _todoUnderusedFrames); + TrimIfCold( + ExteriorSeedPortals, + exteriorSeedPortalCount, + ref _exteriorSeedPortalsUnderusedFrames); } internal ViewPolygon CopyPolygon(ReadOnlySpan vertices) @@ -178,11 +207,14 @@ public sealed class PortalVisibilityFrame private void ReturnCellViews(Dictionary views) { foreach (CellView view in views.Values) - { - view.Reset(); - if (view.IsRetainable && _cellViewPool.Count < MaxRetainedCellViews) - _cellViewPool.Push(view); - } + ReturnCellView(view); + } + + private void ReturnCellView(CellView view) + { + view.Reset(); + if (view.IsRetainable && _cellViewPool.Count < MaxRetainedCellViews) + _cellViewPool.Push(view); } private static void TrimIfCold( @@ -243,6 +275,15 @@ public sealed class PortalVisibilityFrame } } +/// +/// One outside-facing portal accepted as an exterior building-view seed. +/// is the exact installed-view-clipped aperture. +/// +public readonly record struct ExteriorPortalSeed( + uint CellId, + int PortalIndex, + CellView View); + public static class PortalVisibilityBuilder { // Side-classification epsilon. Retail's is F_EPSILON = 0.000199999995 @@ -710,10 +751,27 @@ public static class PortalVisibilityBuilder // ever built from a knife-edge aperture. if (i < cell.ClipPlanes.Count) { - if (CameraOnInteriorSide(cell, i, cameraPos)) - continue; if (EyeInPlaneOfPortal(cell, i, cameraPos)) continue; + + // Do NOT reuse the ordinary EnvCell traversal tolerance + // here. PortalSideEpsilon deliberately admits a 1 cm + // stale-root margin, but retail ConstructView(CBldPortal) + // classifies the seed with Sidedness's exact F_EPSILON + // (0.0002 m). At Sanctuary the 0104 and 0106 exterior + // planes coincide; the chase eye can sit ~4 mm outside the + // opposite cathedral half while its EnvCell root remains + // 0104. Applying the 1 cm margin calls that eye "inside" + // 0106 and drops the entire look-in flood, exposing the + // landscape/waterfall through half the cathedral. + if (CameraOnInteriorSide( + cell, + i, + cameraPos, + SeedInPlaneEpsilon)) + { + continue; + } } float seedDistance = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos); @@ -741,6 +799,17 @@ public static class PortalVisibilityBuilder if (clippedRegion.Count == 0) continue; + // Preserve the exact CBldPortal that produced this view. The + // renderer must punch only accepted seed apertures; iterating + // every OtherCellId==0xFFFF portal on the reached cell turns a + // single accepted opening into unrelated far-depth holes. + var seedPortalView = frame.RentCellView(); + AddRegion(seedPortalView, clippedRegion); + frame.ExteriorSeedPortals.Add(new ExteriorPortalSeed( + cell.CellId, + i, + seedPortalView)); + var seedView = GetOrCreate(frame, frame.CellViews, cell.CellId); bool grew = AddRegion(seedView, clippedRegion); @@ -1068,13 +1137,17 @@ public static class PortalVisibilityBuilder // InitCell leaves the in-plane case a CANDIDATE for cell portals (Ghidra // 0x005a4b70); building/exterior SEED portals additionally reject in-plane // via EyeInPlaneOfPortal (retail ConstructView(CBldPortal) IN_PLANE → 0). - private static bool CameraOnInteriorSide(LoadedCell cell, int portalIndex, Vector3 cameraPos) + private static bool CameraOnInteriorSide( + LoadedCell cell, + int portalIndex, + Vector3 cameraPos, + float epsilon = PortalSideEpsilon) { var plane = cell.ClipPlanes[portalIndex]; if (plane.Normal.LengthSquared() < 1e-8f) return true; // no usable plane → allow var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform); float dot = Vector3.Dot(plane.Normal, localCam) + plane.D; - return plane.InsideSide == 0 ? dot >= -PortalSideEpsilon : dot <= PortalSideEpsilon; + return plane.InsideSide == 0 ? dot >= -epsilon : dot <= epsilon; } // T2 (BR-4): retail ConstructView(CBldPortal)'s Sidedness IN_PLANE reject diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs index 04708dcb..5b90654a 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs @@ -134,6 +134,8 @@ internal sealed class RetailPViewPassExecutor : private readonly TerrainDrawDiagnosticsController _terrainDiagnostics; private readonly RetailPViewParticleClassifications _particleClassifications = new(); private readonly HashSet _noSceneParticleEntityIds = []; + private readonly Dictionary _singleCellClipRouting = new(1); + private readonly Dictionary _noCellClipRouting = new(0); /// /// Borrowed until the next late landscape pass. The outdoor-root post-world @@ -207,6 +209,8 @@ internal sealed class RetailPViewPassExecutor : { List? failures = null; TryAbort(_frameGlState.RestoreFrameDefaults); + TryAbort(() => _envCells.SetClipRouting(null)); + TryAbort(_entities.ClearClipRouting); TryAbort(_particleClassifications.BeginFrame); TryAbort(_noSceneParticleEntityIds.Clear); if (failures is { Count: > 0 }) @@ -230,6 +234,11 @@ internal sealed class RetailPViewPassExecutor : ClipFrameAssembly reuseAssembly) => ClipFrameAssembler.Assemble(_clipFrame, portalFrame, reuseAssembly); + public void AppendLookInClipFrames( + IReadOnlyList lookInFrames, + ClipFrameAssembly assembly) => + ClipFrameAssembler.AppendLookInFrames(_clipFrame, lookInFrames, assembly); + public void PrepareClipFrame(int terrainUploadCount) => _surface.PrepareClipFrame(terrainUploadCount); @@ -246,6 +255,24 @@ internal sealed class RetailPViewPassExecutor : _entities.ClearClipRouting(); } + public void UseCellPortalViewRouting(uint cellId, ClipViewSlice slice) + { + _singleCellClipRouting.Clear(); + _singleCellClipRouting.Add(cellId, slice.Slot); + _envCells.SetClipRouting(_singleCellClipRouting); + // Retail DrawMesh only viewcone-checks an object's sphere under the + // installed PortalList and then draws the mesh whole. Hard clipping the + // object here slices a stationary player when the chase camera crosses + // into the opposite cathedral cell while the player remains behind. + _entities.ClearClipRouting(); + } + + private void UseOutdoorPortalViewRouting(ClipViewSlice slice) => + _entities.SetClipRouting( + _noCellClipRouting, + outdoorSlot: slice.Slot, + outdoorVisible: true); + public void PrepareCellBatches( RetailPViewFrameInput frame, HashSet visibleCellIds) => @@ -376,6 +403,7 @@ internal sealed class RetailPViewPassExecutor : if (scissor) _surface.EndScissor(); + _entities.ClearClipRouting(); DisableClipDistances(); } @@ -418,8 +446,7 @@ internal sealed class RetailPViewPassExecutor : _particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds); - if (!frame.RootCell.IsOutdoorNode - && _particleClassifications.Outdoor.Count > 0 + if (_particleClassifications.Outdoor.Count > 0 && _particles is not null && _particleRenderer is not null) { @@ -427,7 +454,8 @@ internal sealed class RetailPViewPassExecutor : frame.Camera, frame.CameraWorldPosition, ParticleRenderPass.Scene, - _particleClassifications.Outdoor); + _particleClassifications.Outdoor, + clipSlot: (uint)context.Slice.Slot); } EnableClipDistances(); @@ -456,6 +484,77 @@ internal sealed class RetailPViewPassExecutor : if (scissor) _surface.EndScissor(); + _entities.ClearClipRouting(); + DisableClipDistances(); + } + + public void DrawLandscapeStaticParticles( + RetailPViewFrameInput frame, + RetailPViewLandscapeStaticParticleContext context) + { + bool scissor = BeginDoorwayScissor(context.Slice.NdcAabb); + _surface.BindTerrainClip(); + DisableClipDistances(); + + _particleClassifications.ReplaceOutdoor(context.ParticleOwnerIds); + if (_particleClassifications.Outdoor.Count > 0 + && _particles is not null + && _particleRenderer is not null) + { + _particleRenderer.DrawForOwners( + frame.Camera, + frame.CameraWorldPosition, + ParticleRenderPass.Scene, + _particleClassifications.Outdoor, + clipSlot: (uint)context.Slice.Slot); + } + + if (scissor) + _surface.EndScissor(); + _entities.ClearClipRouting(); + DisableClipDistances(); + } + + public void DrawLandscapeBuildingShellSlice( + RetailPViewFrameInput frame, + RetailPViewLandscapeBuildingShellSliceContext context) + { + UseOutdoorPortalViewRouting(context.Slice); + bool scissor = BeginDoorwayScissor(context.Slice.NdcAabb); + _surface.BindTerrainClip(); + DisableClipDistances(); + + if (context.EntityDraw is RenderFrameEntityDrawRequest request) + { + RenderFrameView drawView = request.View; + _entities.DrawPackedProductionRoute( + frame.Camera, + in drawView, + request.Route, + request.RouteIndex, + request.CellId, + request.TupleLandblockId); + } + else if (context.BuildingShells.Count > 0) + { + var buildingEntry = ( + frame.PlayerLandblockId ?? 0u, + Vector3.Zero, + Vector3.Zero, + context.BuildingShells, + (IReadOnlyDictionary?)null); + _entities.Draw( + frame.Camera, + new[] { buildingEntry }, + frame.Frustum, + neverCullLandblockId: frame.PlayerLandblockId, + visibleCellIds: null, + animatedEntityIds: frame.AnimatedEntityIds); + } + + if (scissor) + _surface.EndScissor(); + _entities.ClearClipRouting(); DisableClipDistances(); } @@ -468,10 +567,13 @@ internal sealed class RetailPViewPassExecutor : public void DrawLookInPortalPunch( RetailPViewFrameInput frame, - RetailPViewCellSliceContext context) => - DrawPortalDepthWrite(context, frame, forceFarZ: true); + RetailPViewCellSliceContext context, + int portalIndex) => + DrawPortalDepthWrite(context, frame, forceFarZ: true, portalIndex); - public void DrawUnattachedSceneParticles(RetailPViewFrameInput frame) + public void DrawUnattachedSceneParticles( + RetailPViewFrameInput frame, + ClipViewSlice slice) { if (_particles is null || _particleRenderer is null) return; @@ -482,7 +584,8 @@ internal sealed class RetailPViewPassExecutor : frame.CameraWorldPosition, ParticleRenderPass.Scene, _noSceneParticleEntityIds, - includeUnattached: true); + includeUnattached: true, + clipSlot: (uint)slice.Slot); } public void FlushLandscapeAlpha() => _alpha.Flush(); @@ -509,7 +612,8 @@ internal sealed class RetailPViewPassExecutor : frame.Camera, frame.CameraWorldPosition, ParticleRenderPass.Scene, - visible); + visible, + clipSlot: (uint)context.Slice.Slot); DisableClipDistances(); } @@ -552,7 +656,8 @@ internal sealed class RetailPViewPassExecutor : private void DrawPortalDepthWrite( RetailPViewCellSliceContext context, RetailPViewFrameInput frame, - bool forceFarZ) + bool forceFarZ, + int? onlyPortalIndex = null) { // Retail D3DPolyRender::DrawPortalPolyInternal @ 0x0059BC90. // Main interior roots stamp true depth (seal); outdoor and look-in @@ -566,6 +671,8 @@ internal sealed class RetailPViewPassExecutor : Span world = stackalloc Vector3[32]; for (int index = 0; index < cell.Portals.Count; index++) { + if (onlyPortalIndex.HasValue && index != onlyPortalIndex.Value) + continue; if (cell.Portals[index].OtherCellId != 0xFFFF) continue; if (index >= cell.PortalPolygons.Count) diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index d39df8f8..0aa5b947 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -50,8 +50,10 @@ public sealed class RetailPViewRenderer private readonly Stack _lookInFramePool = new(); private readonly HashSet _lookInPrepareScratch = new(); - // #131/#132: the late landscape phase's scene-particle owner survivors - // (statics + outside-stage dynamics passing the slice cone). + // #131/#132: landscape scene-particle owner survivors. With building + // look-ins, static owners use the pre-building alpha barrier and the late + // phase contains only outside-stage dynamics; otherwise the late phase + // carries both sets. private readonly HashSet _lateParticleOwnerScratch = new(); private readonly HashSet _cellParticleOwnerScratch = new(); private readonly HashSet _dynamicParticleOwnerScratch = new(); @@ -144,6 +146,7 @@ public sealed class RetailPViewRenderer var clipAssembly = passes.AssembleClipFrame( pvFrame, _clipAssemblyScratch); + passes.AppendLookInClipFrames(_lookInFrames, clipAssembly); int terrainUploadCount = checked(1 + clipAssembly.OutsideViewSlices.Length * 2); passes.PrepareClipFrame(terrainUploadCount); @@ -413,6 +416,9 @@ public sealed class RetailPViewRenderer group, ctx.ViewerEyePos, ctx.Cells.Find, ctx.ViewProjection, OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons, reuseFrame: frameScratch); + LoadedCell sourceCell = group[0]; + frame.SourceBuildingKey = sourceCell.BuildingId ?? sourceCell.CellId; + frame.SourceBuildingLandblockId = sourceCell.CellId & 0xFFFF0000u; if (frame.OrderedVisibleCells.Count > 0) _lookInFrames.Add(frame); else @@ -454,65 +460,67 @@ public sealed class RetailPViewRenderer // then draw the flooded cells' shells + statics far→near (the nested // DrawCells' DrawEnvCell + DrawObjCellForDummies; its outside_view is // empty by construction — PView ctor draw_landscape=0 — so no recursive - // landscape/clear/seal). Anything rasterized outside an aperture is - // repainted by the root's own shells after the depth clear, so over-draw - // here is color-safe; statics draw whole (the main viewcone has no entry - // for look-in cells; over-include is the safe direction). + // landscape/clear/seal). Retail CEnvCell::setup_view installs every cell's + // nested portal_view before DrawEnvCell, while DrawMesh iterates that same + // PortalList for cell objects. Preserve that per-slice gate here; drawing a + // nested cell whole lets its floor, details, and emitters escape the authored + // aperture even when the outer depth choreography is otherwise correct. private void DrawBuildingLookIns( RetailPViewFrameInput ctx, IRetailPViewPassExecutor passes, ClipFrameAssembly clipAssembly, InteriorEntityPartition.Result? partition, + ViewconeCuller viewcone, IRenderFrameEntityPassExecutor? frameEntityPasses, in RenderFrameView frameView) { if (_lookInFrames.Count == 0) return; - foreach (var frame in _lookInFrames) + int outsideSliceCount = clipAssembly.OutsideViewSlices.Length; + int lookInRouteIndex = 0; + for (int frameIndex = 0; frameIndex < _lookInFrames.Count; frameIndex++) { + PortalVisibilityFrame frame = _lookInFrames[frameIndex]; + + // Retail enters DrawBuilding once per building and drains every + // alpha submission accumulated by the preceding building before + // punching the next building's portals. The first building uses + // the pre-look-in barrier in DrawLandscapeThroughOutsideView. + if (frameIndex > 0) + passes.FlushLandscapeAlpha(); + // Pass 1: far-Z punch every aperture of this building. - foreach (uint cellId in frame.OrderedVisibleCells) + foreach (ExteriorPortalSeed seed in frame.ExteriorSeedPortals) { - if (!frame.CellViews.TryGetValue(cellId, out var view)) - continue; - foreach (var poly in view.Polygons) + foreach (var poly in seed.View.Polygons) { var cps = ClipPlaneSet.From(poly); if (cps.IsNothingVisible) continue; passes.DrawLookInPortalPunch(ctx, new RetailPViewCellSliceContext( - cellId, + seed.CellId, new ClipViewSlice( 0, new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY), cps.PlaneArray), - NoParticleOwners)); + NoParticleOwners), + seed.PortalIndex); } } - // Pass 2: shells + statics, far→near. - passes.UseIndoorMembershipOnlyRouting(); - - // Opaque shells batched per building into ONE Render (this building's - // aperture punches above already ran; z-buffer handles order and - // lighting is per-instance CellId-keyed) — was one heavy per-frame - // Render per cell. Per-cell entity/particle work stays in the loop. - _shellBatch.Clear(); - foreach (uint cid in frame.OrderedVisibleCells) - _shellBatch.Add(cid); - if (_shellBatch.Count > 0) - passes.DrawOpaqueCellShells(_shellBatch); - + // Pass 2: shells + objects, far→near, once per portal_view slice. for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--) { uint cellId = frame.OrderedVisibleCells[i]; - _oneCell.Clear(); - _oneCell.Add(cellId); - // Opaque shell batched above. Transparent stays per-cell (far→near) - // for correct compositing; skipped for opaque-only cells. - if (passes.CellHasTransparentShell(cellId)) - passes.DrawTransparentCellShells(_oneCell); + var clipKey = new LookInClipCell(frameIndex, cellId); + if (!clipAssembly.LookInCellToViewSlices.TryGetValue( + clipKey, + out ClipViewSlice[]? cellSlices) + || cellSlices.Length == 0) + { + continue; + } _cellStaticScratch.Clear(); if (partition is not null @@ -528,8 +536,7 @@ public sealed class RetailPViewRenderer // post-clear they would z-fail against the root's seal anyway // (the #118 lesson). Retail draws a look-in cell's objects // inside the NESTED DrawCells (DrawObjCellForDummies, - // pc:432878+), i.e. right here in the landscape stage. Drawn - // WHOLE like the statics (AP-33's documented over-include). + // pc:432878+), i.e. right here in the landscape stage. // No double-draw: dynamics-last keeps culling them (their // cell is absent from the main cone), and their emitters ride // the DrawCellParticles call below, not DrawDynamicsParticles @@ -541,48 +548,154 @@ public sealed class RetailPViewRenderer _cellStaticScratch.Add(e); } - if (frameEntityPasses is not null) + foreach (ClipViewSlice slice in cellSlices) { - RenderFrameRouteOwnerSelector.Replace( - _cellParticleOwnerScratch, - in frameView, - RenderFrameCandidateRoute.LookInObject, - i, - cellId); - } - else - { - ReplaceOwnerIds( - _cellParticleOwnerScratch, - _cellStaticScratch); - } + int routeIndex = lookInRouteIndex++; + passes.UseCellPortalViewRouting(cellId, slice); + _oneCell.Clear(); + _oneCell.Add(cellId); + passes.DrawOpaqueCellShells(_oneCell); + if (passes.CellHasTransparentShell(cellId)) + passes.DrawTransparentCellShells(_oneCell); - if (frameEntityPasses is not null - || _cellStaticScratch.Count > 0) - { - _candidateObserver?.ObservePViewBucket( - CurrentRenderPViewRoute.LookInObject, - i, - cellId, - _cellStaticScratch); - DrawEntityRouteOrLegacy( - ctx, - passes, - frameEntityPasses, - in frameView, - RenderFrameCandidateRoute.LookInObject, - i, - cellId, - _cellStaticScratch, - _oneCell); + if (frameEntityPasses is not null) + { + RenderFrameRouteOwnerSelector.Replace( + _cellParticleOwnerScratch, + in frameView, + RenderFrameCandidateRoute.LookInObject, + routeIndex, + cellId); + } + else + { + ReplaceOwnerIds( + _cellParticleOwnerScratch, + _cellStaticScratch); + } - // The cell-particles pass for look-in cells — retail's - // nested DrawCells draws objects WITH their emitters. - foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) + if (frameEntityPasses is not null + || _cellStaticScratch.Count > 0) + { + _candidateObserver?.ObservePViewBucket( + CurrentRenderPViewRoute.LookInObject, + routeIndex, + cellId, + _cellStaticScratch); + DrawEntityRouteOrLegacy( + ctx, + passes, + frameEntityPasses, + in frameView, + RenderFrameCandidateRoute.LookInObject, + routeIndex, + cellId, + _cellStaticScratch, + _oneCell); + + // The nested DrawCells object pass includes emitters and + // retains the exact setup_view clip until alpha playback. passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext( cellId, slice, _cellParticleOwnerScratch)); + } } } + + // The ordinary exterior building shell is clipped by the outer + // outside_view, not by the nested cell PortalList. + passes.UseIndoorMembershipOnlyRouting(); + + // Retail's ordinary shell pass immediately follows this same + // building's portal-only pass. Pair by the shell's authored + // anchor EnvCell; never let an unrelated building repaint a + // look-in merely because both happen to be nearby. + int sliceIndex = 0; + foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices) + { + int shellRouteIndex = LookInBuildingShellRouteIndex( + frameIndex, + outsideSliceCount, + sliceIndex); + _buildingShellScratch.Clear(); + if (partition is not null) + { + foreach (WorldEntity entity in partition.OutdoorStatic) + { + if (!entity.IsBuildingShell + || FindLookInFrameIndex( + entity.BuildingShellAnchorCellId ?? 0, + _lookInFrames, + ctx.Cells) != frameIndex) + { + continue; + } + + EntitySphere(entity, out Vector3 center, out float radius); + if (viewcone.SphereVisibleInOutsideSlice( + sliceIndex, + center, + radius)) + { + _buildingShellScratch.Add(entity); + } + } + } + + _candidateObserver?.ObservePViewBucket( + CurrentRenderPViewRoute.LandscapeBuildingShell, + shellRouteIndex, + 0, + _buildingShellScratch); + bool hasPackedShell = frameEntityPasses is not null + && HasExactRoute( + in frameView, + RenderFrameCandidateRoute.LandscapeBuildingShell, + shellRouteIndex, + 0); + if (hasPackedShell || _buildingShellScratch.Count > 0) + { + RenderFrameEntityDrawRequest? shellDraw = + frameEntityPasses is null + ? null + : new RenderFrameEntityDrawRequest( + frameView, + RenderFrameCandidateRoute.LandscapeBuildingShell, + shellRouteIndex, + 0, + ctx.PlayerLandblockId ?? 0); + passes.DrawLandscapeBuildingShellSlice( + ctx, + new RetailPViewLandscapeBuildingShellSliceContext( + slice, + _buildingShellScratch) + { + EntityDraw = shellDraw, + }); + + _lateParticleOwnerScratch.Clear(); + if (frameEntityPasses is not null) + { + RenderFrameRouteOwnerSelector.Replace( + _lateParticleOwnerScratch, + in frameView, + RenderFrameCandidateRoute.LandscapeBuildingShell, + shellRouteIndex, + 0); + } + else + { + ReplaceOwnerIds( + _lateParticleOwnerScratch, + _buildingShellScratch); + } + passes.DrawLandscapeStaticParticles( + ctx, + new RetailPViewLandscapeStaticParticleContext( + slice, + _lateParticleOwnerScratch)); + } + sliceIndex++; + } } } @@ -598,18 +711,13 @@ public sealed class RetailPViewRenderer if (clipAssembly.OutsideViewSlices.Length == 0) return; - // #131/#132 (the FlushAlphaList deferral): retail collects ALL alpha - // draws of the landscape stage and flushes them ONCE after LScape::draw - // (D3DPolyRender::FlushAlphaList, DrawCells pc:432722) — so translucent - // landscape content (portal swirl meshes, flame particles) composites - // AFTER the building look-ins. Our dispatcher draws translucency inside - // each Draw call, so the stage is split in TWO phases instead: EARLY = - // sky + terrain + outdoor STATIC meshes (the look-in punches need their - // depth to mark against, the #117 lesson); then the look-ins; then - // LATE = outside-stage dynamics' meshes + ALL scene particles + - // weather. Content drawn early and overlapped by a look-in aperture - // was otherwise overpainted by the far interior (translucents write no - // depth to protect themselves) — the portal-swirl/candle-flame class. + // #131/#132: retail drains the remaining landscape alpha after + // LScape::draw (DrawCells pc:432720), while each DrawBuilding is also + // an earlier alpha barrier before its portal traversal (pc:427954). + // Our dispatcher batches outdoor content, so the stage is split into: + // EARLY sky/terrain/static meshes; an optional pre-look-in static-alpha + // barrier; building look-ins; then LATE outside-stage dynamics, + // remaining particles, and weather; followed by the outer flush. int probeSliceIndex = 0; foreach (var slice in clipAssembly.OutsideViewSlices) { @@ -628,6 +736,14 @@ public sealed class RetailPViewRenderer { foreach (var e in partition.OutdoorStatic) { + if (e.IsBuildingShell + && FindLookInFrameIndex( + e.BuildingShellAnchorCellId ?? 0, + _lookInFrames, + ctx.Cells) >= 0) + { + continue; + } EntitySphere(e, out var c, out float r); if (viewcone.SphereVisibleInOutsideSlice( probeSliceIndex, @@ -663,15 +779,81 @@ public sealed class RetailPViewRenderer }); } + // Retail DrawBuilding flushes every alpha submission accumulated before + // the building immediately before its portal-only traversal + // (RenderDeviceD3D::DrawBuilding pc:427954-427956). That barrier is + // essential at open-air seams: foliage and static emitters encountered + // before the building must not be flushed after the look-in cell floor + // and repaint it. Our outdoor statics are one retained batch rather than + // retail's BSP-by-building walk, so use one barrier before the first + // look-in; DrawBuildingLookIns adds the corresponding barrier between + // each later building pair. Submit the early static owners' particles + // into the same alpha queue first; their mesh alpha was already + // submitted by the EARLY entity route above. + bool hasBuildingLookIns = _lookInFrames.Count > 0; + if (hasBuildingLookIns) + { + int barrierSliceIndex = 0; + foreach (var slice in clipAssembly.OutsideViewSlices) + { + // Ownerless outdoor emitters cannot ride an entity route. Retail + // draws their meshes once for every installed outside_view; + // retain that slot through deferred alpha playback. + passes.DrawUnattachedSceneParticles(ctx, slice); + + _lateParticleOwnerScratch.Clear(); + if (partition is not null) + { + foreach (var e in partition.OutdoorStatic) + { + if (e.IsBuildingShell + && FindLookInFrameIndex( + e.BuildingShellAnchorCellId ?? 0, + _lookInFrames, + ctx.Cells) >= 0) + { + continue; + } + EntitySphere(e, out var c, out float r); + if (viewcone.SphereVisibleInOutsideSlice( + barrierSliceIndex, + c, + r)) + { + _lateParticleOwnerScratch.Add(e.Id); + } + } + } + if (frameEntityPasses is not null) + { + RenderFrameRouteOwnerSelector.Replace( + _lateParticleOwnerScratch, + in frameView, + RenderFrameCandidateRoute.LandscapeOutdoorStatic, + barrierSliceIndex, + 0); + } + + passes.DrawLandscapeStaticParticles( + ctx, + new RetailPViewLandscapeStaticParticleContext( + slice, + _lateParticleOwnerScratch)); + barrierSliceIndex++; + } + passes.FlushLandscapeAlpha(); + } + // #124: far-building look-ins draw HERE — still inside the landscape // stage (their punches mark against the terrain/exterior depth just - // drawn), strictly BEFORE the depth clear + seals below, matching + // drawn), strictly BEFORE the outer depth clear + seals below, matching // retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785). DrawBuildingLookIns( ctx, passes, clipAssembly, partition, + viewcone, frameEntityPasses, in frameView); @@ -687,8 +869,8 @@ public sealed class RetailPViewRenderer passes.ClearClipRouting(); _outdoorStaticScratch.Clear(); // late: dynamics survivors - _lateParticleOwnerScratch.Clear(); // late: statics + dynamics survivors - if (partition is not null) + _lateParticleOwnerScratch.Clear(); // late: dynamics, plus statics without look-ins + if (!hasBuildingLookIns && partition is not null) { foreach (var e in partition.OutdoorStatic) { @@ -712,12 +894,19 @@ public sealed class RetailPViewRenderer } if (frameEntityPasses is not null) { - RenderFrameRouteOwnerSelector.Replace( - _lateParticleOwnerScratch, - in frameView, - RenderFrameCandidateRoute.LandscapeOutdoorStatic, - probeSliceIndex, - 0); + if (hasBuildingLookIns) + { + _lateParticleOwnerScratch.Clear(); + } + else + { + RenderFrameRouteOwnerSelector.Replace( + _lateParticleOwnerScratch, + in frameView, + RenderFrameCandidateRoute.LandscapeOutdoorStatic, + probeSliceIndex, + 0); + } RenderFrameRouteOwnerSelector.Union( _lateParticleOwnerScratch, in frameView, @@ -753,13 +942,19 @@ public sealed class RetailPViewRenderer // #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls, // campfires, ground effects anchored at a position) have no owner id - // to ride any of the id-filtered particle passes. The outdoor root - // has the dedicated T3 pass for them; an INTERIOR root had NO pass - // at all. Draw them ONCE per frame (not per slice — alpha particles - // must not double-draw, the #121 lesson), at the END of the landscape - // stage: after the clear they would z-fail against the doorway seal. - if (!ctx.RootCell.IsOutdoorNode) - passes.DrawUnattachedSceneParticles(ctx); + // to ride any of the id-filtered particle passes. Draw once per + // installed outside_view for BOTH root kinds, matching retail's + // landscape-stage placement and preserving the slot in each deferred + // draw. The former outdoor-root post-world tail ran after building + // cells and let exterior alpha repaint the cathedral transition. + // With no look-ins they drain at the end of the landscape stage; the + // look-in path submits them at its pre-building barrier so later opaque + // cell floors can cover them. + if (!hasBuildingLookIns) + { + foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices) + passes.DrawUnattachedSceneParticles(ctx, slice); + } // Retail PView::DrawCells 0x005A4872 drains the landscape alpha list // immediately after LScape::draw and before the optional depth clear. @@ -779,6 +974,58 @@ public sealed class RetailPViewRenderer passes.UseIndoorMembershipOnlyRouting(); } + internal static int LookInBuildingShellRouteIndex( + int frameIndex, + int outsideSliceCount, + int sliceIndex) => + checked((frameIndex * outsideSliceCount) + sliceIndex); + + internal static int FindLookInFrameIndex( + uint buildingShellAnchorCellId, + IReadOnlyList lookInFrames, + IRetailPViewCellSource cells) + { + if (buildingShellAnchorCellId == 0) + return -1; + + LoadedCell? anchorCell = cells.Find(buildingShellAnchorCellId); + if (anchorCell is null) + return -1; + + uint buildingKey = anchorCell.BuildingId ?? anchorCell.CellId; + uint landblockId = anchorCell.CellId & 0xFFFF0000u; + for (int frameIndex = 0; frameIndex < lookInFrames.Count; frameIndex++) + { + PortalVisibilityFrame frame = lookInFrames[frameIndex]; + if (frame.SourceBuildingKey == buildingKey + && frame.SourceBuildingLandblockId == landblockId) + { + return frameIndex; + } + } + + return -1; + } + + private static bool HasExactRoute( + in RenderFrameView view, + RenderFrameCandidateRoute route, + int routeIndex, + uint cellId) + { + foreach (RenderFrameCandidateRange range in view.RouteRanges) + { + if (range.Route == route + && range.RouteIndex == routeIndex + && range.CellId == cellId + && range.Count > 0) + { + return true; + } + } + return false; + } + private void DrawExitPortalMasks( RetailPViewFrameInput ctx, IRetailPViewPassExecutor passes, @@ -1180,6 +1427,7 @@ public sealed class RetailPViewRenderer // T3 scratch lists (render thread only; cleared per use). private readonly List _outdoorStaticScratch = new(); + private readonly List _buildingShellScratch = new(); private readonly List _cellStaticScratch = new(); private readonly List _dynamicsScratch = new(); // #118: dynamics assigned to the OUTSIDE stage this frame (interior roots @@ -1343,10 +1591,14 @@ public interface IRetailPViewPassExecutor ClipFrameAssembly AssembleClipFrame( PortalVisibilityFrame portalFrame, ClipFrameAssembly reuseAssembly); + void AppendLookInClipFrames( + IReadOnlyList lookInFrames, + ClipFrameAssembly assembly); void PrepareClipFrame(int terrainUploadCount); void SetTerrainClip(ReadOnlySpan planes); void ClearClipRouting(); void UseIndoorMembershipOnlyRouting(); + void UseCellPortalViewRouting(uint cellId, ClipViewSlice slice); void PrepareCellBatches( RetailPViewFrameInput frame, HashSet visibleCellIds); @@ -1363,11 +1615,22 @@ public interface IRetailPViewPassExecutor ClipViewSlice slice, int sliceIndex); void DrawLandscapeSlice(RetailPViewFrameInput frame, RetailPViewLandscapeSliceContext context); + void DrawLandscapeStaticParticles( + RetailPViewFrameInput frame, + RetailPViewLandscapeStaticParticleContext context); + void DrawLandscapeBuildingShellSlice( + RetailPViewFrameInput frame, + RetailPViewLandscapeBuildingShellSliceContext context); void DrawLandscapeSliceLate(RetailPViewFrameInput frame, RetailPViewLandscapeLateSliceContext context); void ClearInteriorDepth(); void DrawExitPortalMask(RetailPViewFrameInput frame, RetailPViewCellSliceContext context); - void DrawLookInPortalPunch(RetailPViewFrameInput frame, RetailPViewCellSliceContext context); - void DrawUnattachedSceneParticles(RetailPViewFrameInput frame); + void DrawLookInPortalPunch( + RetailPViewFrameInput frame, + RetailPViewCellSliceContext context, + int portalIndex); + void DrawUnattachedSceneParticles( + RetailPViewFrameInput frame, + ClipViewSlice slice); void FlushLandscapeAlpha(); void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context); void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlySet ownerIds); @@ -1690,9 +1953,27 @@ public readonly record struct RetailPViewLandscapeSliceContext( internal RenderFrameEntityDrawRequest? EntityDraw { get; init; } } +/// +/// Outdoor-static emitters submitted at retail's pre-building alpha barrier. +/// Mesh alpha for the same owners is already queued by the early landscape +/// entity route. +/// +public readonly record struct RetailPViewLandscapeStaticParticleContext( + ClipViewSlice Slice, + IReadOnlySet ParticleOwnerIds); + +/// Retail DrawBuilding's ordinary exterior-shell pass, issued after +/// the same building's portal-only look-in traversal. +public readonly record struct RetailPViewLandscapeBuildingShellSliceContext( + ClipViewSlice Slice, + IReadOnlyList BuildingShells) +{ + internal RenderFrameEntityDrawRequest? EntityDraw { get; init; } +} + /// #131/#132: the late landscape phase's per-slice payload — -/// outside-stage dynamics to mesh-draw, plus the full scene-particle owner -/// set (statics + dynamics cone survivors) the attached-emitter filter keys on. +/// outside-stage dynamics to mesh-draw, plus the particle owners not already +/// submitted at a pre-building barrier. public readonly record struct RetailPViewLandscapeLateSliceContext( ClipViewSlice Slice, IReadOnlyList Dynamics, diff --git a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs index 3b339bd3..506362c2 100644 --- a/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs +++ b/src/AcDream.App/Rendering/Scene/CurrentRenderSceneOracle.cs @@ -30,6 +30,7 @@ internal readonly record struct CurrentRenderProjectionFingerprint( internal enum CurrentRenderPViewRoute : byte { LandscapeOutdoorStatic, + LandscapeBuildingShell, LandscapeOutsideDynamic, LookInObject, CellStatic, diff --git a/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs index db928b46..f867a395 100644 --- a/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs +++ b/src/AcDream.App/Rendering/Scene/RenderFrameProduct.cs @@ -13,6 +13,7 @@ internal enum RenderFrameBlendClass : byte internal enum RenderFrameCandidateRoute : byte { LandscapeOutdoorStatic, + LandscapeBuildingShell, LandscapeOutsideDynamic, LookInObject, CellStatic, diff --git a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs index bbd368bd..61f2d3e7 100644 --- a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs +++ b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs @@ -992,6 +992,8 @@ internal sealed class RenderScenePViewFrameProductController : { CurrentRenderPViewRoute.LandscapeOutdoorStatic => RenderFrameCandidateRoute.LandscapeOutdoorStatic, + CurrentRenderPViewRoute.LandscapeBuildingShell => + RenderFrameCandidateRoute.LandscapeBuildingShell, CurrentRenderPViewRoute.LandscapeOutsideDynamic => RenderFrameCandidateRoute.LandscapeOutsideDynamic, CurrentRenderPViewRoute.LookInObject => @@ -1157,7 +1159,21 @@ internal sealed class RenderScenePViewFrameBuilder LoadSceneIndices(input.Scene); BuildOutdoorRoutes(writer, in input); - BuildLookInRoutes(writer, in input); + int lookInRouteIndex = 0; + for (int frameIndex = 0; + frameIndex < input.LookInFrames.Count; + frameIndex++) + { + BuildLookInRoutes( + writer, + in input, + frameIndex, + ref lookInRouteIndex); + BuildLookInBuildingShellRoutes( + writer, + in input, + frameIndex); + } BuildOutsideDynamicRoutes(writer, in input); BuildCellStaticRoute(writer, in input); BuildDynamicLastRoute(writer, in input); @@ -1279,6 +1295,14 @@ internal sealed class RenderScenePViewFrameBuilder for (int i = 0; i < _outdoorCount; i++) { RenderProjectionRecord record = _outdoor[i]; + if (record.EntityPayload.IsBuildingShell + && RetailPViewRenderer.FindLookInFrameIndex( + record.Source.BuildingShellAnchorCellId, + input.LookInFrames, + input.Cells) >= 0) + { + continue; + } Sphere(in record, out Vector3 center, out float radius); if (!input.Viewcone.SphereVisibleInOutsideSlice( sliceIndex, @@ -1304,22 +1328,84 @@ internal sealed class RenderScenePViewFrameBuilder } } + private void BuildLookInBuildingShellRoutes( + RenderFrameWriter writer, + in RenderScenePViewBuildInput input, + int frameIndex) + { + int sliceCount = input.ClipAssembly.OutsideViewSlices.Length; + for (int sliceIndex = 0; sliceIndex < sliceCount; sliceIndex++) + { + int count = 0; + EnsureCapacity(ref _survivors, _outdoorCount); + for (int i = 0; i < _outdoorCount; i++) + { + RenderProjectionRecord record = _outdoor[i]; + if (!record.EntityPayload.IsBuildingShell + || RetailPViewRenderer.FindLookInFrameIndex( + record.Source.BuildingShellAnchorCellId, + input.LookInFrames, + input.Cells) != frameIndex) + { + continue; + } + + Sphere(in record, out Vector3 center, out float radius); + if (!input.Viewcone.SphereVisibleInOutsideSlice( + sliceIndex, + in center, + radius)) + { + continue; + } + + _survivors[count++] = record; + writer.AddOutdoor(in record); + AddProjection( + writer, + in record, + input.AnimatedEntityIds); + } + + int routeIndex = + RetailPViewRenderer.LookInBuildingShellRouteIndex( + frameIndex, + sliceCount, + sliceIndex); + writer.AddRouteRange( + RenderFrameCandidateRoute.LandscapeBuildingShell, + routeIndex, + 0, + _survivors.AsSpan(0, count)); + } + } + private void BuildLookInRoutes( RenderFrameWriter writer, - in RenderScenePViewBuildInput input) + in RenderScenePViewBuildInput input, + int frameIndex, + ref int routeIndex) { - for (int frameIndex = 0; - frameIndex < input.LookInFrames.Count; - frameIndex++) + PortalVisibilityFrame frame = input.LookInFrames[frameIndex]; + for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--) { - PortalVisibilityFrame frame = input.LookInFrames[frameIndex]; - for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--) + uint cellId = frame.OrderedVisibleCells[i]; + var clipKey = new LookInClipCell(frameIndex, cellId); + if (!input.ClipAssembly.LookInCellToViewSlices.TryGetValue( + clipKey, + out ClipViewSlice[]? slices) + || slices.Length == 0) { - uint cellId = frame.OrderedVisibleCells[i]; - int count = LoadCell( - input.Scene, - cellId, - includeDynamics: true); + continue; + } + + int count = LoadCell( + input.Scene, + cellId, + includeDynamics: true); + for (int sliceIndex = 0; sliceIndex < slices.Length; sliceIndex++) + { + int currentRouteIndex = routeIndex++; if (count == 0) continue; @@ -1330,7 +1416,7 @@ internal sealed class RenderScenePViewFrameBuilder input.AnimatedEntityIds); writer.AddRouteRange( RenderFrameCandidateRoute.LookInObject, - i, + currentRouteIndex, cellId, _cell.AsSpan(0, count)); } diff --git a/src/AcDream.App/Rendering/Shaders/particle.vert b/src/AcDream.App/Rendering/Shaders/particle.vert index 3da9560f..7df885dd 100644 --- a/src/AcDream.App/Rendering/Shaders/particle.vert +++ b/src/AcDream.App/Rendering/Shaders/particle.vert @@ -14,6 +14,23 @@ layout(location = 5) in vec4 aColor; // (ACDREAM_TEXTURE_HANDLE, injected by // tools/ShaderCompiler/VulkanGlslPreamble.cs). layout(location = 6) in uint aTextureIndex; +layout(location = 7) in uint aClipSlot; + +struct CellClip { + uint count; + uint _p0; + uint _p1; + uint _p2; + vec4 planes[8]; +}; +layout(std430, binding = 2) readonly buffer ClipRegionBuf { + CellClip clipRegions[]; +}; + +out gl_PerVertex { + vec4 gl_Position; + float gl_ClipDistance[8]; +}; uniform mat4 uViewProjection; @@ -35,4 +52,9 @@ void main() { // stage, which is the only form Vulkan can express. vTextureIndex = aTextureIndex; gl_Position = uViewProjection * vec4(world, 1.0); + CellClip clip = clipRegions[aClipSlot]; + for (uint i = 0u; i < clip.count; ++i) + gl_ClipDistance[i] = dot(clip.planes[i], gl_Position); + for (uint i = clip.count; i < 8u; ++i) + gl_ClipDistance[i] = 1.0; } diff --git a/src/AcDream.App/Rendering/Shaders/particle_mesh.vert b/src/AcDream.App/Rendering/Shaders/particle_mesh.vert index 812b2af8..6d6542f1 100644 --- a/src/AcDream.App/Rendering/Shaders/particle_mesh.vert +++ b/src/AcDream.App/Rendering/Shaders/particle_mesh.vert @@ -5,6 +5,23 @@ layout(location = 1) in vec3 aNormal; layout(location = 2) in vec2 aTexCoord; layout(location = 3) in mat4 aModel; layout(location = 7) in vec4 aColor; +layout(location = 8) in uint aClipSlot; + +struct CellClip { + uint count; + uint _p0; + uint _p1; + uint _p2; + vec4 planes[8]; +}; +layout(std430, binding = 2) readonly buffer ClipRegionBuf { + CellClip clipRegions[]; +}; + +out gl_PerVertex { + vec4 gl_Position; + float gl_ClipDistance[8]; +}; uniform mat4 uViewProjection; @@ -15,4 +32,9 @@ void main() { vTexCoord = aTexCoord; vColor = aColor; gl_Position = uViewProjection * aModel * vec4(aPosition, 1.0); + CellClip clip = clipRegions[aClipSlot]; + for (uint i = 0u; i < clip.count; ++i) + gl_ClipDistance[i] = dot(clip.planes[i], gl_Position); + for (uint i = clip.count; i < 8u; ++i) + gl_ClipDistance[i] = 1.0; } diff --git a/src/AcDream.App/Rendering/Shaders/spv/particle.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/particle.vert.spv index 26fcf5ded4f34d0649720f770a2061090cbc68ac..9a52c2bf69392e9933780ffe33e4c9769babb24b 100644 GIT binary patch literal 2832 zcmZA1U2j!Y5C-7go_?s676b|^)KjWeK?*2E6oFRJHnB<-F8sQwctaFoB6?wr!CN&7 zYSf?MpVO#`iSM(|o@9rWyk}|DRecrcg?_63K6CxYeR>ELK^JU9`o z2Iqq9;H_YznP~SeL?7>LdDtrQ%gMIb^0U=vtJU|b%QhFk9{RR^*psb)(DpXl>JzQb zSH0+&J=OXL&2yb^GrQ^5%t$oFsf!!+KWJWvF5hN)p<8N;FMB%n?t8eU*jB~Ki(Bh) z^5QP_ICXKGJ4|KK}hOylbXMsQ0*=EI+?H#b~e<)Huwe0r- z`S9Y|-U)7h?ER6$z&h61^CPp}e;~S8Ijf#`g{%0-I$OQRqnoAbvAsK7)nATozxby* zU(Lt%4lCaL-eJW*)A{r*AKQP1c==W$#g?D#ZQ`omQ_B-oekEU%%@7+S%qilXvUBi?!ta zJBFYnFGb{yW)mmC$pJNjHu@q41)VBK4L7*1{Puxc+x9|l$XNOXDdReLGA`h50z zvrh)g!G)ZncgwE6jzvG(`Mgm!pKo?DI1$))ytAE+J9<8FZ@mlg?(2nCcXuY+vs#a> zAFH^RqvOVU+$$aDTW#l5ecMZc{o?u8g1gDf_iCWlC}y(#zj-b8YM>wevc=UbZ%22h z*VCE%d^Ok(#PgZUeZ3R#)i-wzcRPMPy}7f$1^vD5^gp?2??$?#mGkiai-}*N;KN~MEU7y~`w}C$FuWxz1$eR26=;jjhb?|vmbAK6~|5RY^FCzIZ lbMv$D^3to{ucEI8dXYoFxV51ARgbUwt^VZuFE{UO@IPq%mjVC) literal 1844 zcmZA1UsFy|6bA59Zz_@`{Xz03@+T51kyJ!oG$X@>3pZxmxZs8lz!xy%+qyC1`JMM{ zbJ#P_yVvuqwbxo_pLO0&|H4o;*jJ5Iqt%P5x5lgfsxRh9HCXTahp!KpzkL0$yt=j` z;#AeC1I3J0fw6iSG&#ifgh{=ow}z}wLMVVe5Dt?;)B6KPTb3Gjr6v^TJO$! zHA|cv;yg#u*tA~nojRYmgD4x>Eq#eEe4$qV)rO*-ZpEoDZl|TmOWSK{>eCKdnqFv! zElm%!qn73lXm47YK4@=S+Hj-2%f6qd_Z)he$gh1qTBp&bV#(VLrgI1K;r#fChU>8# z?(RG*eY4?aj#jw&!JR*oT|RZ4pUBQf7mu4S+GO}-_HJM;HGHZ^>+{pG#mZUgnMYcQ zztnK`uEh2%r5fpX%ntNNz-VNk+5BPdu&4fS5F0c1Em$zspXyV$K z3-mnndvz`S{)%G0x1#E4G%!OitKoY7 z{1?!`@=S7+c;B5FEAjpXX<(K3-Bd9zGl3a1M{?-zTtNSk7&Cu9kca-g(JsW+3ty>y zF}8Utwf#5I!1}*-H=5esZ>c>WyBn0+mt)IAFSQq9tIy}0`Mnk_22XN}W*9GhEyXsM zeC8GB^Db8cGskB(ad+drJr2A}vm)L*+zRNg663CR0{vJ;+l@`@YiUm#tv~xwZq>Ve j7VHG#`OU@88`I2viW!4n|frW#B$d{WeEQdufN(90I2r+1a7dZe298AEg z8vhXgIgFZ+@ciECHg6|2RnJq^)m7c~&TwjRw#*F5T-jKDEt9piOqD@QdK5iZW;(pO z_VwD*PmdogeR}eQ$W3KPb-vAIb7xQI_tCxYe+1S?<{~^>BfBH>k;TZN$mfxBk<~KY z#qwFOeehJpa=_~1mk*xp@UZNR7JH?v>+G0a4!Uvn%B$+t{O*mO?3wQD!0evH^I7t5 ziOA77^;?aT7xzVvlNYzrfbk(j%_*sNTl49-Y}Z=h*vhk83?T!A_6}^z ztjO7LtH-;U8-BUi3laMp{xG%$pNrP78n-1%+(L4wlf5&RT1JswiFcv&Y%6x54r6ueN-!cT@)~9$u`RyZtz}-0kiTRv%tGe5i7I5&KD`l<`G(W%l(l zxvSZ3&RgA0i`kbG%&hNVKDL>J)p#b_tmB*sn@Jr1*=Rk;#}AuH9RG!Av2wTfT#0RM z&lFybZtrj{cH4I=w!ZZw&+}+`oYV5Wh~4Jym+IG#_?H#y|5Uy+^Dfqs_n$ESht7vJ z+j5)h3-Nj{n9u(AB4(7&EW^v$?L9{kZ)}Fdd&`B$sE0p_?Op%K9{vLnF?f5t=i!LA z{3b)CV9{JvsLH-8@+p@q&sWBT&kNft5iF zNHYO3HxM%;iGkQ4F%}?p2g-;|{Hr`!f{ACc10&018Ag%GE{wC-nHgk&(oB ViewerRoot is null || RootSeenOutside; + + /// + /// The camera has an EnvCell root whose authored visibility does not reach + /// outdoors. SeenOutside cells are open-air presentation cells, not an + /// indoor environment gate merely because they have portal geometry. + /// + public bool CameraInsideEnclosedCell => CameraInsideCell && !RootSeenOutside; + + /// Environment gate shared by directional shadows and other + /// outdoor-only render-pack effects. + public bool PlayerOrCameraInsideEnclosedCell => + PlayerInsideCell || CameraInsideEnclosedCell; + + public bool IsAtmosphericallyOutdoor => + RenderSky && !CameraInsideEnclosedCell; } /// Borrowed building scratch, valid only until the next build. diff --git a/src/AcDream.App/Rendering/WorldScenePassExecutor.cs b/src/AcDream.App/Rendering/WorldScenePassExecutor.cs index af85081e..fe14a5fc 100644 --- a/src/AcDream.App/Rendering/WorldScenePassExecutor.cs +++ b/src/AcDream.App/Rendering/WorldScenePassExecutor.cs @@ -221,17 +221,10 @@ internal sealed class WorldScenePassExecutor : IWorldScenePassExecutor return AppendSignature(currentSignature, "global"); } - if (clipRoot.IsOutdoorNode) - { - _particleRenderer.DrawForOwners( - camera.Camera, - camera.Position, - ParticleRenderPass.Scene, - outdoorOwnerIds, - includeUnattached: true); - return AppendSignature(currentSignature, "unattached"); - } - + // Every PView root, including the outdoor sentinel, now submits scene + // particles inside LScape::draw. Replaying them here is both a duplicate + // and too late: it occurs after nested building cells, allowing exterior + // waterfall/foliage alpha to repaint an indoor/outdoor transition. return currentSignature; } diff --git a/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs b/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs index 679d7a1f..d8c624ee 100644 --- a/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs @@ -128,6 +128,39 @@ public class ClipFrameAssemblerTests Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode); } + [Fact] + public void AppendLookInFrames_PacksEveryNestedCellViewWithoutReplacingMainRoutes() + { + const uint mainCell = 0xA9B40100; + const uint lookInCell = 0xA9B40106; + var main = new PortalVisibilityFrame(); + main.CellViews[mainCell] = ViewOf(Square(0f, 0f, 0.8f)); + main.OrderedVisibleCells.Add(mainCell); + main.OutsideView.Add(Square(0f, 0f, 0.7f)); + + var nested = new PortalVisibilityFrame(); + nested.CellViews[lookInCell] = ViewOf( + Square(-0.35f, 0f, 0.15f), + Square(0.35f, 0f, 0.15f)); + nested.OrderedVisibleCells.Add(lookInCell); + + using ClipFrame frame = ClipFrame.NoClip(); + ClipFrameAssembly assembly = ClipFrameAssembler.Assemble(frame, main); + int mainSlot = assembly.CellIdToSlot[mainCell]; + int slotsBeforeLookIn = frame.SlotCount; + + ClipFrameAssembler.AppendLookInFrames(frame, [nested], assembly); + + var key = new LookInClipCell(0, lookInCell); + ClipViewSlice[] slices = assembly.LookInCellToViewSlices[key]; + Assert.Equal(2, slices.Length); + Assert.All(slices, slice => Assert.True(slice.Slot >= slotsBeforeLookIn)); + Assert.NotEqual(slices[0].Slot, slices[1].Slot); + Assert.Equal(slices[0].Slot, assembly.LookInCellToSlot[key]); + Assert.Equal(mainSlot, assembly.CellIdToSlot[mainCell]); + Assert.DoesNotContain(lookInCell, assembly.CellIdToSlot.Keys); + } + [Fact] public void Assemble_OutsideViewWithExitPortal_HasOutsideViewTrue_AabbMatchesBounds() { diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs index 63ce718f..620a8fb6 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs @@ -42,9 +42,12 @@ public sealed class VulkanShaderManifestTests ["mesh_modern.frag.spv"] = "b702b644862aca31ce1fb0677adc5872b39c4ea87f595a89363b44d10f2cc50e", ["mesh_modern.vert.spv"] = "7ca5fb241c4f0248884ba8fa88fbae17a7d5cbc80efe4ac4a9ffd0254012ead8", ["particle.frag.spv"] = "680da227704e0b3afa9b5226a7d73dd65aa9d8759d081cf4d5009d30e148726b", - ["particle.vert.spv"] = "ed79461ab347bf17edaca714bbbbfabead8192e059c760578ca3a1a01409799e", + // Re-pinned 2026-08-27: portal-view clip slots now travel with + // deferred billboard particles, matching retail PortalList draws. + ["particle.vert.spv"] = "bf0f6b7b26a6b237e4abb2973b9959a38338868fd8304b3863f593ad56d61c35", ["particle_mesh.frag.spv"] = "7696b1dc0613b5a724c55df465173f613ae047da9675895b149b7c71b009cc7c", - ["particle_mesh.vert.spv"] = "f7fe8b203cadcd4d54af5cdbcfd9d5bf733146e10bafa78ca730fb6970db0479", + // Same contract for full-mesh particle geometry. + ["particle_mesh.vert.spv"] = "b5b3e0f583e00b78b56e297e60e5050013a26b3a74dbec64f6e5b286b751f0fa", ["portal_depth.frag.spv"] = "96755196d4d0da7be4792107557465778be2ebefb5584834cc75bf90ec55a6cc", ["portal_depth.vert.spv"] = "cd113860b7acd6afad3ebcc0a68dd7147f6baae729df51ab360c123588dc3ae2", // sky.frag re-pinned 2026-08-23: the dome's fog blend lost its diff --git a/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs b/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs index 69aaabfb..9d355c3d 100644 --- a/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs +++ b/tests/AcDream.App.Tests/Rendering/ParticleBindlessInstanceTests.cs @@ -14,11 +14,15 @@ public sealed class ParticleBindlessInstanceTests // one TextureIndex (a binding=9 handle-table slot, 4 bytes), so the // struct shrank by 4 bytes; TextureIndex keeps TextureHandleLow's // former offset (64 — right after the four vec4 fields). - Assert.Equal(68, Marshal.SizeOf()); + Assert.Equal(72, Marshal.SizeOf()); Assert.Equal( new IntPtr(64), Marshal.OffsetOf( nameof(ParticleRenderer.BillboardGpuInstance.TextureIndex))); + Assert.Equal( + new IntPtr(68), + Marshal.OffsetOf( + nameof(ParticleRenderer.BillboardGpuInstance.ClipSlot))); } [Fact] @@ -40,6 +44,8 @@ public sealed class ParticleBindlessInstanceTests // index rather than by a null handle, because Vulkan's descriptor array // cannot be asked whether an element was ever written. Assert.Contains("layout(location = 6) in uint aTextureIndex;", vertex); + Assert.Contains("layout(location = 7) in uint aClipSlot;", vertex); + Assert.Contains("clipRegions[aClipSlot]", vertex); Assert.Contains("flat out uint vTextureIndex;", vertex); Assert.Contains("vTextureIndex = aTextureIndex;", vertex); Assert.Contains("#extension GL_ARB_bindless_texture : require", fragment); diff --git a/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs b/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs index 9a5b3f5c..b8d560ba 100644 --- a/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs @@ -21,6 +21,10 @@ public sealed class RenderScenePViewFrameProductTests RenderProjectionRecord outdoor = Record( 0x0100_0000_0000_0001, RenderProjectionClass.OutdoorStatic); + RenderProjectionRecord buildingShell = Record( + 0x0100_0000_0000_0008, + RenderProjectionClass.OutdoorStatic, + isBuildingShell: true); RenderProjectionRecord hidden = Record( 0x0100_0000_0000_0002, RenderProjectionClass.OutdoorStatic, @@ -48,6 +52,7 @@ public sealed class RenderScenePViewFrameProductTests RenderProjectionRecord[] records = [ outdoor, + buildingShell, hidden, withdrawn, shell, @@ -90,7 +95,7 @@ public sealed class RenderScenePViewFrameProductTests try { Assert.Equal( - [outdoor.Id, hidden.Id], + [outdoor.Id, hidden.Id, buildingShell.Id], view.OutdoorStaticCandidates.ToArray() .Select(static item => item.Id)); Assert.Equal( @@ -101,28 +106,42 @@ public sealed class RenderScenePViewFrameProductTests [outdoorDynamic.Id, cellDynamic.Id], view.DynamicCandidates.ToArray() .Select(static item => item.Id)); - Assert.Equal(5, view.Transforms.Length); - Assert.Equal(5, view.RouteCandidates.Length); + Assert.Equal(6, view.Transforms.Length); + Assert.Equal(6, view.RouteCandidates.Length); Assert.Equal(3, view.RouteRanges.Length); + RenderFrameCandidateRange outdoorRange = Assert.Single( + view.RouteRanges.ToArray(), + range => range.Route + == RenderFrameCandidateRoute.LandscapeOutdoorStatic); + Assert.Equal(3, outdoorRange.Count); + Assert.Contains( + view.RouteCandidates.Slice( + outdoorRange.Offset, + outdoorRange.Count).ToArray(), + item => item.Id == buildingShell.Id); + Assert.DoesNotContain( + view.RouteRanges.ToArray(), + range => range.Route + == RenderFrameCandidateRoute.LandscapeBuildingShell); Assert.DoesNotContain( view.RouteCandidates.ToArray(), item => item.Id == withdrawn.Id || item.Id == shell.Id); Assert.Equal( new RenderFrameDiagnosticCounts( - OutdoorStaticCandidates: 2, + OutdoorStaticCandidates: 3, CellStaticCandidates: 1, DynamicCandidates: 2, - TransformCount: 5, + TransformCount: 6, OpaqueClassificationCount: 0, AlphaClassificationCount: 0, LightSetCount: 0, SelectionPartCount: 0, - RouteCandidateCount: 5, - EntityCandidateCount: 5, - MeshPartCount: 5), + RouteCandidateCount: 6, + EntityCandidateCount: 6, + MeshPartCount: 6), view.DiagnosticCounts); - Assert.Equal(5, view.EntityCandidates.Length); - Assert.Equal(5, view.MeshParts.Length); + Assert.Equal(6, view.EntityCandidates.Length); + Assert.Equal(6, view.MeshParts.Length); Assert.Same(portal, view.PortalFrame); Assert.Same(clip, view.ClipAssembly); Assert.Equal(digest, view.SourceDigest); @@ -133,6 +152,178 @@ public sealed class RenderScenePViewFrameProductTests } } + [Fact] + public void Builder_OrdersMultiSliceBuildingShellsAfterLookIns() + { + using var scene = new ArchRenderScene(Generation); + RenderProjectionRecord outdoor = Record( + 0x0100_0000_0000_0101, + RenderProjectionClass.OutdoorStatic); + RenderProjectionRecord buildingShell = Record( + 0x0100_0000_0000_0102, + RenderProjectionClass.OutdoorStatic, + isBuildingShell: true, + buildingShellAnchorCellId: Cell); + RenderProjectionRecord lookInObject = Record( + 0x0100_0000_0000_0103, + RenderProjectionClass.IndoorCellStatic, + parentCell: Cell); + scene.Apply( + [ + RenderProjectionDelta.Register(Generation, 1, outdoor), + RenderProjectionDelta.Register(Generation, 2, buildingShell), + RenderProjectionDelta.Register(Generation, 3, lookInObject), + ]); + + PortalVisibilityFrame portal = Portal(Cell); + PortalVisibilityFrame lookIn = Portal(Cell); + lookIn.SourceBuildingKey = 2u; + lookIn.SourceBuildingLandblockId = Cell & 0xFFFF0000u; + var anchorCell = new LoadedCell + { + CellId = Cell, + BuildingId = 2u, + }; + ClipFrameAssembly clip = TwoSliceClip(Cell); + clip.LookInCellToViewSlices[new LookInClipCell(0, Cell)] = + [clip.CellIdToViewSlices[Cell][0]]; + ViewconeCuller viewcone = + ViewconeCuller.Build(clip, Matrix4x4.Identity); + var exchange = new RenderFrameExchange(); + var builder = new RenderScenePViewFrameBuilder(); + RenderSceneDigest digest = + scene.BuildDigest(new RenderSceneDigestBuffer()); + var input = new RenderScenePViewBuildInput( + scene.OpenQuery(), + digest, + portal, + clip, + viewcone, + [lookIn], + [Cell], + new DictionaryCellSource(anchorCell), + [], + RootIsOutdoor: true); + + builder.Build(exchange, frameSequence: 1, in input); + RenderFrameView view = exchange.BorrowLatest(Generation, 1); + try + { + Assert.Equal( + [ + (RenderFrameCandidateRoute.LandscapeOutdoorStatic, 0, 0u), + (RenderFrameCandidateRoute.LandscapeOutdoorStatic, 1, 0u), + (RenderFrameCandidateRoute.LookInObject, 0, Cell), + (RenderFrameCandidateRoute.LandscapeBuildingShell, 0, 0u), + (RenderFrameCandidateRoute.LandscapeBuildingShell, 1, 0u), + (RenderFrameCandidateRoute.CellStatic, 0, 0u), + ], + view.RouteRanges.ToArray().Select(static range => + (range.Route, range.RouteIndex, range.CellId))); + } + finally + { + exchange.Release(in view); + } + } + + [Fact] + public void Builder_InterleavesEachLookInWithItsOwnPackedBuildingShellRoutes() + { + const uint secondCell = 0xA9B40180u; + using var scene = new ArchRenderScene(Generation); + RenderProjectionRecord firstShell = Record( + 0x0100_0000_0000_0201, + RenderProjectionClass.OutdoorStatic, + isBuildingShell: true, + buildingShellAnchorCellId: Cell); + RenderProjectionRecord secondShell = Record( + 0x0100_0000_0000_0202, + RenderProjectionClass.OutdoorStatic, + isBuildingShell: true, + buildingShellAnchorCellId: secondCell); + RenderProjectionRecord firstObject = Record( + 0x0100_0000_0000_0203, + RenderProjectionClass.IndoorCellStatic, + parentCell: Cell); + RenderProjectionRecord secondObject = Record( + 0x0100_0000_0000_0204, + RenderProjectionClass.IndoorCellStatic, + parentCell: secondCell); + scene.Apply( + [ + RenderProjectionDelta.Register(Generation, 1, firstShell), + RenderProjectionDelta.Register(Generation, 2, secondShell), + RenderProjectionDelta.Register(Generation, 3, firstObject), + RenderProjectionDelta.Register(Generation, 4, secondObject), + ]); + + PortalVisibilityFrame portal = Portal(Cell); + PortalVisibilityFrame firstLookIn = Portal(Cell); + firstLookIn.SourceBuildingKey = 2u; + firstLookIn.SourceBuildingLandblockId = Cell & 0xFFFF0000u; + PortalVisibilityFrame secondLookIn = Portal(secondCell); + secondLookIn.SourceBuildingKey = 3u; + secondLookIn.SourceBuildingLandblockId = secondCell & 0xFFFF0000u; + var firstAnchor = new LoadedCell + { + CellId = Cell, + BuildingId = 2u, + }; + var secondAnchor = new LoadedCell + { + CellId = secondCell, + BuildingId = 3u, + }; + ClipFrameAssembly clip = TwoSliceClip(Cell); + clip.LookInCellToViewSlices[new LookInClipCell(0, Cell)] = + [clip.CellIdToViewSlices[Cell][0]]; + clip.LookInCellToViewSlices[new LookInClipCell(1, secondCell)] = + [clip.CellIdToViewSlices[Cell][0]]; + ViewconeCuller viewcone = + ViewconeCuller.Build(clip, Matrix4x4.Identity); + var exchange = new RenderFrameExchange(); + var builder = new RenderScenePViewFrameBuilder(); + RenderSceneDigest digest = + scene.BuildDigest(new RenderSceneDigestBuffer()); + var input = new RenderScenePViewBuildInput( + scene.OpenQuery(), + digest, + portal, + clip, + viewcone, + [firstLookIn, secondLookIn], + [Cell], + new DictionaryCellSource(firstAnchor, secondAnchor), + [], + RootIsOutdoor: true); + + builder.Build(exchange, frameSequence: 1, in input); + RenderFrameView view = exchange.BorrowLatest(Generation, 1); + try + { + Assert.Equal( + [ + (RenderFrameCandidateRoute.LookInObject, 0, Cell), + (RenderFrameCandidateRoute.LandscapeBuildingShell, 0, 0u), + (RenderFrameCandidateRoute.LandscapeBuildingShell, 1, 0u), + (RenderFrameCandidateRoute.LookInObject, 1, secondCell), + (RenderFrameCandidateRoute.LandscapeBuildingShell, 2, 0u), + (RenderFrameCandidateRoute.LandscapeBuildingShell, 3, 0u), + ], + view.RouteRanges.ToArray() + .Where(static range => + range.Route is RenderFrameCandidateRoute.LookInObject + or RenderFrameCandidateRoute.LandscapeBuildingShell) + .Select(static range => + (range.Route, range.RouteIndex, range.CellId))); + } + finally + { + exchange.Release(in view); + } + } + [Fact] public void Builder_ReusesOrderedIndicesWhileRefreshingDirtyRecords() { @@ -501,7 +692,9 @@ public sealed class RenderScenePViewFrameProductTests uint? parentCell = null, RenderProjectionFlags flags = RenderProjectionFlags.Draw - | RenderProjectionFlags.SpatiallyResident) + | RenderProjectionFlags.SpatiallyResident, + bool isBuildingShell = false, + uint buildingShellAnchorCellId = 0) { uint fullCellId = parentCell ?? Landblock; Matrix4x4 transform = Matrix4x4.Identity; @@ -531,14 +724,14 @@ public sealed class RenderScenePViewFrameProductTests SourceId: 1, ParentCellId: parentCell ?? 0, EffectCellId: 0, - BuildingShellAnchorCellId: 0, + BuildingShellAnchorCellId: buildingShellAnchorCellId, TransformFingerprint: default, GeometryFingerprint: default, AppearanceFingerprint: default), new RenderEntityPayload( [new MeshRef((uint)id, Matrix4x4.Identity)], PaletteOverride: null, - IsBuildingShell: false)); + IsBuildingShell: isBuildingShell)); } private static WorldEntity Entity( @@ -578,6 +771,22 @@ public sealed class RenderScenePViewFrameProductTests return assembly; } + private static ClipFrameAssembly TwoSliceClip(uint cellId) + { + var first = new ClipViewSlice( + Slot: 0, + NdcAabb: new Vector4(-1, -1, 0, 1), + Planes: []); + var second = new ClipViewSlice( + Slot: 1, + NdcAabb: new Vector4(0, -1, 1, 1), + Planes: []); + var assembly = new ClipFrameAssembly(); + assembly.SetOutsideViewSlices([first, second]); + assembly.CellIdToViewSlices[cellId] = [first]; + return assembly; + } + private static ( uint LandblockId, Vector3 AabbMin, @@ -602,4 +811,15 @@ public sealed class RenderScenePViewFrameProductTests public LoadedCell? Find(uint cellId) => null; } + + private sealed class DictionaryCellSource : IRetailPViewCellSource + { + private readonly Dictionary _cells; + + public DictionaryCellSource(params LoadedCell[] cells) => + _cells = cells.ToDictionary(static cell => cell.CellId); + + public LoadedCell? Find(uint cellId) => + _cells.TryGetValue(cellId, out LoadedCell? cell) ? cell : null; + } } diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs index f00a019a..71068650 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs @@ -23,6 +23,7 @@ public sealed class RetailPViewPassExecutorTests [ "begin", "assemble", + "append-look-in-clips", "prepare-clip:3", "indoor-routing", "prepare-cells", @@ -33,6 +34,7 @@ public sealed class RetailPViewPassExecutorTests "terrain-clip", "clear-routing", "landscape-late", + "unattached-particles", "landscape-alpha", "indoor-routing", "indoor-routing", @@ -272,6 +274,88 @@ public sealed class RetailPViewPassExecutorTests executor); Assert.Contains("look-in-punch", executor.Operations); + AssertAppearsInOrder( + string.Join('|', executor.Operations), + "landscape-early", + "unattached-particles", + "landscape-static-particles", + "landscape-alpha", + "look-in-punch", + "landscape-late", + "landscape-alpha", + "interior-depth-clear"); + } + + [Fact] + public void DrawInside_repaints_the_exterior_building_shell_after_its_look_in() + { + var renderer = new RetailPViewRenderer(); + using var executor = new RecordingExecutor(); + LoadedCell root = InteriorWithExit(0xA9B40100u); + root.BuildingId = 1u; + LoadedCell[] building = NearbyTwoCellBuilding(); + WorldEntity exteriorShell = Entity( + 0x700u, + isBuildingShell: true, + buildingShellAnchorCellId: building[0].CellId); + + renderer.DrawInside( + Frame( + root, + [exteriorShell], + nearbyBuildingCells: building, + additionalCells: building), + executor); + + AssertAppearsInOrder( + string.Join('|', executor.Operations), + "landscape-early", + "look-in-punch", + "landscape-building-shell", + "landscape-late"); + } + + [Fact] + public void DrawInside_pairs_each_look_in_with_only_its_own_shell_and_alpha_barrier() + { + var renderer = new RetailPViewRenderer(); + using var executor = new RecordingExecutor(); + LoadedCell root = InteriorWithExit(0xA9B40100u); + root.BuildingId = 1u; + LoadedCell[] first = NearbyTwoCellBuilding(); + LoadedCell[] second = NearbyTwoCellBuilding( + 0xA9B40172u, + 0xA9B40173u, + buildingId: 3u); + LoadedCell[] buildings = [.. first, .. second]; + WorldEntity[] shells = + [ + Entity( + 0x700u, + isBuildingShell: true, + buildingShellAnchorCellId: first[0].CellId), + Entity( + 0x701u, + isBuildingShell: true, + buildingShellAnchorCellId: second[0].CellId), + ]; + + renderer.DrawInside( + Frame( + root, + shells, + nearbyBuildingCells: buildings, + additionalCells: buildings), + executor); + + AssertAppearsInOrder( + string.Join('|', executor.Operations), + "look-in-punch", + "landscape-building-shell", + "landscape-static-particles", + "landscape-alpha", + "look-in-punch", + "landscape-building-shell"); } [Fact] @@ -429,20 +513,21 @@ public sealed class RetailPViewPassExecutorTests return cell; } - private static LoadedCell[] NearbyTwoCellBuilding() + private static LoadedCell[] NearbyTwoCellBuilding( + uint vestibuleId = 0xA9B40170u, + uint roomId = 0xA9B40171u, + uint buildingId = 2u) { - const uint vestibuleId = 0xA9B40170u; - const uint roomId = 0xA9B40171u; var vestibule = new LoadedCell { CellId = vestibuleId, - BuildingId = 2u, + BuildingId = buildingId, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, Portals = [ new CellPortalInfo(0xFFFF, 0, 0, 0), - new CellPortalInfo(0x0171, 1, 0, 0), + new CellPortalInfo((ushort)(roomId & 0xFFFFu), 1, 0, 0), ], ClipPlanes = [ @@ -472,10 +557,17 @@ public sealed class RetailPViewPassExecutorTests var room = new LoadedCell { CellId = roomId, - BuildingId = 2u, + BuildingId = buildingId, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, - Portals = [new CellPortalInfo(0x0170, 0, 0, 1)], + Portals = + [ + new CellPortalInfo( + (ushort)(vestibuleId & 0xFFFFu), + 0, + 0, + 1), + ], }; room.PortalPolygons.Add( [ @@ -490,7 +582,9 @@ public sealed class RetailPViewPassExecutorTests private static WorldEntity Entity( uint id, uint serverGuid = 0, - uint? parentCellId = null) => new() + uint? parentCellId = null, + bool isBuildingShell = false, + uint? buildingShellAnchorCellId = null) => new() { Id = id, ServerGuid = serverGuid, @@ -499,6 +593,8 @@ public sealed class RetailPViewPassExecutorTests Rotation = Quaternion.Identity, MeshRefs = [new MeshRef(1u, Matrix4x4.Identity)], ParentCellId = parentCellId, + IsBuildingShell = isBuildingShell, + BuildingShellAnchorCellId = buildingShellAnchorCellId, }; private static void AssertAppearsInOrder(string source, params string[] needles) @@ -566,6 +662,17 @@ public sealed class RetailPViewPassExecutorTests return ClipFrameAssembler.Assemble(_clipFrame, portalFrame, reuseAssembly); } + public void AppendLookInClipFrames( + IReadOnlyList lookInFrames, + ClipFrameAssembly assembly) + { + Operations.Add("append-look-in-clips"); + ClipFrameAssembler.AppendLookInFrames( + _clipFrame, + lookInFrames, + assembly); + } + public void PrepareClipFrame(int terrainUploadCount) => Operations.Add($"prepare-clip:{terrainUploadCount}"); @@ -574,6 +681,8 @@ public sealed class RetailPViewPassExecutorTests public void ClearClipRouting() => Operations.Add("clear-routing"); public void UseIndoorMembershipOnlyRouting() => Operations.Add("indoor-routing"); + public void UseCellPortalViewRouting(uint cellId, ClipViewSlice slice) => + Operations.Add($"cell-portal-routing:{cellId:X8}:{slice.Slot}"); public void PrepareCellBatches(RetailPViewFrameInput frame, HashSet visibleCellIds) => Operations.Add("prepare-cells"); public void DrawOpaqueCellShells(HashSet cellIds) => Operations.Add("opaque-shells"); @@ -624,10 +733,37 @@ public sealed class RetailPViewPassExecutorTests request.TupleLandblockId); } } + + public void DrawLandscapeStaticParticles( + RetailPViewFrameInput frame, + RetailPViewLandscapeStaticParticleContext context) => + Operations.Add("landscape-static-particles"); + public void DrawLandscapeBuildingShellSlice( + RetailPViewFrameInput frame, + RetailPViewLandscapeBuildingShellSliceContext context) + { + Operations.Add("landscape-building-shell"); + if (context.EntityDraw is RenderFrameEntityDrawRequest request) + { + RenderFrameView view = request.View; + DrawEntityRoute( + frame.Camera, + in view, + request.Route, + request.RouteIndex, + request.CellId, + request.TupleLandblockId); + } + } public void ClearInteriorDepth() => Operations.Add("interior-depth-clear"); public void DrawExitPortalMask(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("exit-mask"); - public void DrawLookInPortalPunch(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("look-in-punch"); - public void DrawUnattachedSceneParticles(RetailPViewFrameInput frame) => Operations.Add("unattached-particles"); + public void DrawLookInPortalPunch( + RetailPViewFrameInput frame, + RetailPViewCellSliceContext context, + int portalIndex) => Operations.Add("look-in-punch"); + public void DrawUnattachedSceneParticles( + RetailPViewFrameInput frame, + ClipViewSlice slice) => Operations.Add("unattached-particles"); public void FlushLandscapeAlpha() => Operations.Add("landscape-alpha"); public void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles"); public void DrawDynamicsParticles( diff --git a/tests/AcDream.App.Tests/Rendering/RhiVertexLayoutStrideTests.cs b/tests/AcDream.App.Tests/Rendering/RhiVertexLayoutStrideTests.cs index 486239bd..6c65f7ad 100644 --- a/tests/AcDream.App.Tests/Rendering/RhiVertexLayoutStrideTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RhiVertexLayoutStrideTests.cs @@ -105,10 +105,9 @@ public class RhiVertexLayoutStrideTests layout.StrideOf(0)); Assert.Equal(GpuVertexInputRate.Vertex, layout.InputRateOf(0)); - // Binding 1 is a mat4 model plus an RGBA colour, written as loose floats - // by WriteMeshGpuInstance. + // Binding 1 is the exact mesh-particle record: model, RGBA, clip slot. Assert.Equal( - (uint)(ParticleRenderer.MeshInstanceFloats * sizeof(float)), + (uint)Unsafe.SizeOf(), layout.StrideOf(1)); Assert.Equal(GpuVertexInputRate.Instance, layout.InputRateOf(1)); } diff --git a/tests/AcDream.App.Tests/Rendering/SanctuaryPortalSeamTests.cs b/tests/AcDream.App.Tests/Rendering/SanctuaryPortalSeamTests.cs new file mode 100644 index 00000000..4152cd92 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/SanctuaryPortalSeamTests.cs @@ -0,0 +1,226 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Sanctuary cathedral seam captured 2026-08-27. The stationary player is in +/// F4180104 while chase-camera zoom crosses the coincident outside portals at +/// world Y=48 and the viewer root switches between two separate buildings: +/// B3={0103,0104,0105} and B4={0106..0111}. The opposite cathedral half must +/// remain available through the interior-root building look-in on both sides. +/// +[Trait("Lane", "InstalledDat")] +public sealed class SanctuaryPortalSeamTests +{ + private const uint Landblock = 0xF4180000u; + private const uint Cell0104 = Landblock | 0x0104u; + private const uint Cell0106 = Landblock | 0x0106u; + + private static Matrix4x4 ViewProjection(Vector3 eye) + { + // Derived from the two exact flap-sweep points. Extending their zoom + // ray reaches the stable chase target at the player's head. + var target = new Vector3(36.033f, 49.638f, 171.353f); + var view = Matrix4x4.CreateLookAt(eye, target, Vector3.UnitZ); + var projection = Matrix4x4.CreatePerspectiveFieldOfView( + 1.2f, 893f / 522f, 1f, 5000f); + return view * projection; + } + + private static IReadOnlyList Cells( + Dictionary cells, + uint firstLow, + uint lastLow) + { + var result = new List(); + for (uint low = firstLow; low <= lastLow; low++) + result.Add(cells[Landblock | low]); + return result; + } + + [Fact] + public void CapturedZoomHandoff_BothRootsBuildTheExactOppositeCathedralSeed() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) + { + Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); + } + + using var dats = new DatCollection(datDir, DatAccessType.Read); + LandBlockInfo info = Assert.IsType(dats.Get(Landblock | 0xfffeu)); + Dictionary cells = + Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, Landblock); + LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out LoadedCell? cell) ? cell : null; + + IReadOnlyList building3 = Cells(cells, 0x0103u, 0x0105u); + IReadOnlyList building4 = Cells(cells, 0x0106u, 0x0111u); + Assert.Equal(0x01001FB2u, info.Buildings[3].ModelId); + Assert.Equal(0x01001FB3u, info.Buildings[4].ModelId); + + var captures = new[] + { + new + { + Name = "near/root0104", + Eye = new Vector3(32.742317f, 48.034306f, 172.447845f), + Root = cells[Cell0104], + Opposite = building4, + ExpectedOppositeCell = Cell0106, + ExpectedSeedPortal = 2, + OppositeBuildingIndex = 4, + }, + new + { + Name = "far/root0106", + Eye = new Vector3(32.308865f, 47.823200f, 172.592255f), + Root = cells[Cell0106], + Opposite = building3, + ExpectedOppositeCell = Cell0104, + ExpectedSeedPortal = 0, + OppositeBuildingIndex = 3, + }, + }; + + foreach (var capture in captures) + { + Matrix4x4 viewProjection = ViewProjection(capture.Eye); + PortalVisibilityFrame main = PortalVisibilityBuilder.Build( + capture.Root, capture.Eye, Lookup, viewProjection); + PortalVisibilityFrame lookIn = PortalVisibilityBuilder.ConstructViewBuilding( + capture.Opposite, + capture.Eye, + Lookup, + viewProjection, + maxSeedDistance: float.PositiveInfinity, + seedRegion: main.OutsideView.Polygons); + + Assert.Contains(capture.ExpectedOppositeCell, lookIn.OrderedVisibleCells); + Assert.True( + lookIn.CellViews.TryGetValue(capture.ExpectedOppositeCell, out CellView? view) + && view.Polygons.Count > 0, + $"{capture.Name} must retain a clipped aperture for the opposite cathedral half"); + ExteriorPortalSeed seed = Assert.Single(lookIn.ExteriorSeedPortals); + Assert.Equal(capture.ExpectedOppositeCell, seed.CellId); + Assert.Equal(capture.ExpectedSeedPortal, seed.PortalIndex); + Assert.NotEmpty(seed.View.Polygons); + + using ClipFrame clipFrame = ClipFrame.NoClip(); + ClipFrameAssembly assembly = ClipFrameAssembler.Assemble( + clipFrame, + main); + ClipFrameAssembler.AppendLookInFrames( + clipFrame, + [lookIn], + assembly); + ClipViewSlice[] nestedSlices = + assembly.LookInCellToViewSlices[ + new LookInClipCell(0, capture.ExpectedOppositeCell)]; + Assert.Equal(view.Polygons.Count, nestedSlices.Length); + Assert.All(nestedSlices, slice => Assert.True(slice.Slot > 0)); + + Assert.Contains( + info.Buildings[capture.OppositeBuildingIndex].Portals, + portal => portal.OtherCellId == (capture.ExpectedOppositeCell & 0xffffu) + && portal.OtherPortalId == capture.ExpectedSeedPortal); + } + } + + [Fact] + public void ReportedLateralTransition_BothRootsKeepTheOppositeFacadePortal() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) + Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory."); + + using var dats = new DatCollection(datDir, DatAccessType.Read); + Dictionary cells = + Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, Landblock); + LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out LoadedCell? cell) ? cell : null; + + static (PortalVisibilityFrame Main, PortalVisibilityFrame LookIn) Build( + Vector3 eye, + Vector3 target, + LoadedCell root, + IReadOnlyList opposite, + Func lookup) + { + Matrix4x4 viewProjection = Matrix4x4.CreateLookAt(eye, target, Vector3.UnitZ) + * Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 1.6f, 0.1f, 5000f); + PortalVisibilityFrame main = PortalVisibilityBuilder.Build( + root, eye, lookup, viewProjection); + PortalVisibilityFrame lookIn = PortalVisibilityBuilder.ConstructViewBuilding( + opposite, eye, lookup, viewProjection, seedRegion: main.OutsideView.Polygons); + return (main, lookIn); + } + + (PortalVisibilityFrame Main, PortalVisibilityFrame LookIn) from0106 = Build( + new Vector3(31.189594f, 46.280170f, 171.783646f), + new Vector3(32.787731f, 46.275948f, 171.354993f), + cells[Cell0106], + Cells(cells, 0x0103u, 0x0105u), + Lookup); + (PortalVisibilityFrame Main, PortalVisibilityFrame LookIn) from0104 = Build( + new Vector3(31.198704f, 49.733287f, 171.783646f), + new Vector3(32.796841f, 49.729065f, 171.354993f), + cells[Cell0104], + Cells(cells, 0x0106u, 0x0111u), + Lookup); + + Assert.Equal([Cell0106, Landblock | 0x010Fu], from0106.Main.OrderedVisibleCells); + Assert.Equal([Cell0104], from0106.LookIn.OrderedVisibleCells); + ExteriorPortalSeed seed0104 = Assert.Single(from0106.LookIn.ExteriorSeedPortals); + Assert.Equal(Cell0104, seed0104.CellId); + Assert.Equal(0, seed0104.PortalIndex); + Assert.NotEmpty(seed0104.View.Polygons); + + Assert.Equal([Cell0104], from0104.Main.OrderedVisibleCells); + Assert.Equal([Cell0106, Landblock | 0x010Fu], from0104.LookIn.OrderedVisibleCells); + ExteriorPortalSeed seed0106 = Assert.Single(from0104.LookIn.ExteriorSeedPortals); + Assert.Equal(Cell0106, seed0106.CellId); + Assert.Equal(2, seed0106.PortalIndex); + Assert.NotEmpty(seed0106.View.Polygons); + } + + [Fact] + public void ReportedExactSteamSeam_Root0104KeepsTheOppositeCathedralPortal() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) + Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory."); + + using var dats = new DatCollection(datDir, DatAccessType.Read); + Dictionary cells = + Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, Landblock); + LoadedCell? Lookup(uint id) => cells.TryGetValue(id, out LoadedCell? cell) ? cell : null; + + // Live ACDREAM_PROBE_CELL capture for the user-reported stationary + // frame at player [32.792290, 48.000618, 169.804993]. The chase eye is + // only ~4 mm across the coincident 0104/0106 exterior portal plane. + var eye = new Vector3(31.189665f, 48.004852f, 171.784988f); + var target = new Vector3(32.792290f, 48.000618f, 171.354993f); + Matrix4x4 viewProjection = Matrix4x4.CreateLookAt(eye, target, Vector3.UnitZ) + * Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1555f / 1019f, 1f, 5000f); + + PortalVisibilityFrame main = PortalVisibilityBuilder.Build( + cells[Cell0104], eye, Lookup, viewProjection); + PortalVisibilityFrame lookIn = PortalVisibilityBuilder.ConstructViewBuilding( + Cells(cells, 0x0106u, 0x0111u), + eye, + Lookup, + viewProjection, + seedRegion: main.OutsideView.Polygons); + + Assert.Contains(Cell0106, lookIn.OrderedVisibleCells); + ExteriorPortalSeed seed = Assert.Single(lookIn.ExteriorSeedPortals); + Assert.Equal(Cell0106, seed.CellId); + Assert.Equal(2, seed.PortalIndex); + Assert.NotEmpty(seed.View.Polygons); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs index 62b92bdf..7bea5c2c 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs @@ -213,6 +213,9 @@ public sealed class WorldRenderFrameBuilderTests Assert.False(result.PlayerSeenOutside); Assert.True(result.RootSeenOutside); Assert.True(result.RenderSky); + Assert.False(result.CameraInsideEnclosedCell); + Assert.True(result.PlayerOrCameraInsideEnclosedCell); + Assert.True(result.IsAtmosphericallyOutdoor); Assert.Equal(playerCellId, result.PlayerCellId); Assert.Equal(viewerCellId, result.ViewerCellId); Assert.Equal(camera.Position, result.ViewerEyePosition); @@ -245,6 +248,9 @@ public sealed class WorldRenderFrameBuilderTests Assert.False(result.PlayerInsideCell); Assert.False(result.CameraInsideCell); Assert.True(result.RenderSky); + Assert.False(result.CameraInsideEnclosedCell); + Assert.False(result.PlayerOrCameraInsideEnclosedCell); + Assert.True(result.IsAtmosphericallyOutdoor); } [Fact] diff --git a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs index ecc5eefe..a8baa7b9 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs @@ -290,7 +290,7 @@ public sealed class WorldSceneRendererTests WorldRenderFrameOutcome result = rig.Renderer.Render(default); Assert.True(result.NormalWorldDrawn); - Assert.Contains("particles:pviewScoped+unattached", rig.Calls); + Assert.Contains("particles:pviewScoped", rig.Calls); Assert.Same(rig.PView.OutdoorSceneParticleEntityIds, rig.Passes.ParticleOwners); Assert.Equal([0xCAFEu], rig.Passes.ParticleOwners); Assert.DoesNotContain("flat:weather", rig.Calls); @@ -809,9 +809,7 @@ public sealed class WorldSceneRendererTests string kind = clipRoot switch { null => "global", - { IsOutdoorNode: true } => currentSignature == "none" - ? "unattached" - : currentSignature + "+unattached", + { IsOutdoorNode: true } => currentSignature, _ => currentSignature, }; calls.Add($"particles:{kind}"); From 4e6e9bc9d9aff787b8f59cf30497e24ef14779d0 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 27 Aug 2026 18:57:21 +0200 Subject: [PATCH 83/89] feat(mosstank): add VTank-style automation PoC --- docs/ISSUES.md | 73 + docs/architecture/acdream-architecture.md | 66 + docs/launch-options.md | 1 + docs/plans/2026-04-24-ui-framework.md | 34 +- .../2026-08-26-mosstank-parity-campaign.md | 483 ++ ...-26-mosstank-vtank-utilitybelt-research.md | 447 ++ .../2026-08-26-mosstank-autocombat-design.md | 95 + src/AcDream.App/AcDream.App.csproj | 40 +- .../InteractionRetainedUiComposition.cs | 15 +- .../Composition/SessionPlayerComposition.cs | 6 +- .../Input/DispatcherMovementInputSource.cs | 2 +- src/AcDream.App/Net/LiveSessionAppSource.cs | 11 +- .../Net/LiveSessionRuntimeFactory.cs | 1 + .../GraphicalWindowBackendSelection.cs | 7 + .../Platform/Win32GlfwActiveWindowGuard.cs | 282 ++ .../Plugins/AppAutomationSurface.cs | 3293 ++++++++++++- src/AcDream.App/Plugins/AppPluginHost.cs | 12 +- src/AcDream.App/Plugins/BufferedUiRegistry.cs | 243 +- src/AcDream.App/Plugins/FilePluginStorage.cs | 86 + .../Plugins/LocalPluginPeerRegistry.cs | 225 + src/AcDream.App/Program.cs | 14 +- src/AcDream.App/Rendering/GameWindow.cs | 48 +- .../Runtime/CurrentGameRuntimeAdapter.cs | 21 + src/AcDream.App/RuntimeOptions.cs | 19 +- .../UI/ItemInteractionController.cs | 299 +- .../ProjectileDebugOverlayController.cs | 137 + src/AcDream.App/UI/MarkupDocument.cs | 479 +- src/AcDream.App/UI/PluginSidePanel.cs | 355 ++ src/AcDream.App/UI/RetailUiRuntime.cs | 106 +- src/AcDream.App/UI/UiElement.cs | 24 +- src/AcDream.App/UI/UiField.cs | 10 + src/AcDream.App/UI/UiMarkupList.cs | 96 + src/AcDream.App/UI/UiMarkupTabButton.cs | 47 + src/AcDream.App/UI/UiMarkupToggle.cs | 75 + src/AcDream.App/UI/UiMenu.cs | 4 +- src/AcDream.App/UI/UiScrollbar.cs | 17 +- .../World/LiveEntityDeletionController.cs | 15 + src/AcDream.Content/MagicCatalog.cs | 20 +- src/AcDream.Core.Net/GameEventWiring.cs | 13 +- .../Messages/InventoryActions.cs | 45 + src/AcDream.Core.Net/WorldSession.cs | 13 + .../packages.win-x64.lock.json | 109 +- src/AcDream.Core/Items/ClientObject.cs | 15 + src/AcDream.Core/Items/ClientObjectTable.cs | 42 +- src/AcDream.Core/Physics/PhysicsEngine.cs | 6 +- src/AcDream.Core/Physics/ResolveResult.cs | 14 +- .../Plugins/PluginCommandRegistry.cs | 142 + .../Plugins/PluginLootClassifierRegistry.cs | 151 + src/AcDream.Core/Plugins/PluginSession.cs | 5 +- src/AcDream.Core/Plugins/ScopedPluginHost.cs | 297 +- .../Hosting/HeadlessLocalPlayerFrameHost.cs | 2 +- .../Hosting/HeadlessSessionHost.cs | 12 +- .../Plugins/HeadlessPluginHost.cs | 5 +- .../Plugins/HeadlessPluginSession.cs | 6 +- src/AcDream.Plugin.Abstractions/Automation.cs | 304 +- .../CombatAutomation.cs | 152 + .../EnchantmentAutomation.cs | 35 + .../EquipmentAutomation.cs | 64 + .../FellowshipAutomation.cs | 72 + .../IPluginHost.cs | 13 + .../IPluginStorage.cs | 22 + .../IUiRegistry.cs | 166 + .../ItemAutomation.cs | 245 + .../LoginAutomation.cs | 25 + .../LootAutomation.cs | 69 + .../LootClassifierPlugins.cs | 93 + .../MagicAutomation.cs | 11 + .../NavigationAutomation.cs | 115 + .../NetworkAutomation.cs | 28 + .../PluginCommands.cs | 48 + .../ProjectileAutomation.cs | 91 + .../RecoveryAutomation.cs | 21 + .../SelectionAutomation.cs | 18 + .../WorldObjectAutomation.cs | 117 + .../WorldTimeAutomation.cs | 20 + .../AcDream.Plugins.MossTank.csproj | 5 +- .../AttackSpellCatalog.cs | 408 ++ .../AutoAttackPower.cs | 173 + src/AcDream.Plugins.MossTank/BuffPlan.cs | 150 +- .../CombatController.cs | 2059 +++++++++ .../CombatFailureTracker.cs | 173 + .../CombatItemDebuffPlanner.cs | 224 + .../CombatSettings.cs | 151 + src/AcDream.Plugins.MossTank/Crafting.cs | 649 +++ .../DebuffScheduler.cs | 400 ++ .../DispelController.cs | 420 ++ .../Expressions/CoreExpressionFunctions.cs | 653 +++ .../Expressions/ExperienceMeter.cs | 84 + .../Expressions/ExpressionEngine.cs | 789 ++++ .../Expressions/ExpressionRuntime.cs | 204 + .../Expressions/ExpressionValue.cs | 240 + .../Expressions/HostExpressionFunctions.cs | 1276 +++++ .../Expressions/MossTankExpressionRuntime.cs | 429 ++ .../Expressions/QuestTracker.cs | 152 + .../Expressions/SalvageStagingManager.cs | 59 + .../Expressions/StatusHudManager.cs | 68 + .../FellowshipManager.cs | 523 +++ .../GrenadeCatalog.cs | 79 + .../InventoryMaintenance.cs | 299 ++ .../ItemManaRecharge.cs | 140 + src/AcDream.Plugins.MossTank/Looting.cs | 1766 +++++++ src/AcDream.Plugins.MossTank/Meta.cs | 656 +++ .../MetaViewManager.cs | 97 + .../MonsterExpression.cs | 608 +++ src/AcDream.Plugins.MossTank/MonsterRules.cs | 150 + .../MossTankCommands.cs | 1046 +++++ .../MossTankLootProfileStore.cs | 477 ++ .../MossTankMetaProfileStore.cs | 286 ++ src/AcDream.Plugins.MossTank/MossTankPanel.cs | 4118 ++++++++++++++++- .../MossTankPlugin.cs | 21 +- .../MossTankProfileRecovery.cs | 46 + .../MossTankProfileStore.cs | 922 ++++ .../MossTankRouteProfileStore.cs | 437 ++ src/AcDream.Plugins.MossTank/Navigation.cs | 1110 +++++ src/AcDream.Plugins.MossTank/PetAutomation.cs | 315 ++ .../PetDeviceCatalog.cs | 51 + .../ProfileGiveController.cs | 247 + .../SpellComponentPolicy.cs | 73 + src/AcDream.Plugins.MossTank/VitalPlan.cs | 219 +- src/AcDream.Plugins.MossTank/VitalRecharge.cs | 1103 +++++ .../VtankAmmunitionDatabase.cs | 164 + .../VtankAmmunitionOptions.tsv | 121 + .../VtankCraftDatabase.cs | 81 + .../VtankCraftRecipes.tsv | 758 +++ .../VtankDamageDatabase.cs | 289 ++ .../VtankLootProfileSerializer.cs | 446 ++ .../VtankLootRequirementEvaluator.cs | 576 +++ .../VtankMetaProfileSerializer.cs | 742 +++ .../VtankNavRouteSerializer.cs | 303 ++ .../VtankOptionCatalog.cs | 221 + .../mosstank-settings.xml | 53 - src/AcDream.Plugins.MossTank/mosstank.xml | 593 ++- .../packages.win-x64.lock.json | 6 +- src/AcDream.Runtime/Chat/ChatCommandRouter.cs | 10 + src/AcDream.Runtime/Chat/ICommandBus.cs | 10 + .../Chat/LiveChatCommandRoute.cs | 11 +- src/AcDream.Runtime/GameRuntimeActionViews.cs | 8 +- .../Gameplay/PlayerMovementController.cs | 50 +- .../Gameplay/RuntimeActionState.cs | 46 +- .../Gameplay/RuntimeCombatAttackState.cs | 18 +- .../Gameplay/RuntimeCombatModeState.cs | 38 + .../Gameplay/RuntimeFriendlyTargetQuery.cs | 32 + .../Gameplay/RuntimeHostileTargetQuery.cs | 146 + .../RuntimeInteractionTransactionState.cs | 41 +- .../RuntimeLocalPlayerMovementState.cs | 2 +- .../Gameplay/RuntimeSpellCastState.cs | 51 +- .../Session/LiveSessionController.cs | 57 + .../packages.win-x64.lock.json | 51 +- .../DispatcherMovementInputSourceTests.cs | 15 + .../Win32GlfwActiveWindowGuardTests.cs | 46 + .../Plugins/AppAutomationSurfaceTests.cs | 277 ++ .../Plugins/BufferedUiRegistryTests.cs | 90 + ...ExternalRenderPackPackageLifecycleTests.cs | 13 +- .../Plugins/FilePluginStorageTests.cs | 35 + .../Plugins/GraphicalPluginSessionTests.cs | 11 +- .../Plugins/LocalPluginPeerRegistryTests.cs | 73 + .../Rendering/LinuxPlatformBoundaryTests.cs | 12 +- .../AcDream.App.Tests/RuntimeOptionsTests.cs | 16 + .../UI/ItemInteractionControllerTests.cs | 192 +- .../ProjectileDebugOverlayControllerTests.cs | 51 + .../UI/MarkupDocumentTests.cs | 166 + .../UI/PluginSidePanelTests.cs | 132 + .../Messages/InventoryActionsTests.cs | 25 + .../WorldSessionInventoryActionTests.cs | 18 + .../Items/ClientObjectTableUpdateTests.cs | 21 + .../Plugins/PluginCommandRegistryTests.cs | 59 + .../Plugins/PluginLoaderTests.cs | 7 +- .../PluginLootClassifierRegistryTests.cs | 80 + .../Plugins/PluginSessionTests.cs | 89 +- .../HeadlessPluginSessionTests.cs | 7 +- .../HeadlessSessionHostTests.cs | 15 + .../AcDream.Plugins.MossTank.Tests.csproj | 5 + .../AttackSpellCatalogTests.cs | 347 ++ .../AutoAttackPowerTests.cs | 118 + .../CombatControllerTests.cs | 1228 +++++ .../CombatFailureTrackerTests.cs | 106 + .../CombatItemDebuffPlannerTests.cs | 182 + .../CraftingTests.cs | 393 ++ .../DebuffSchedulerTests.cs | 219 + .../DispelControllerTests.cs | 375 ++ .../ExpressionEngineTests.cs | 194 + .../FellowshipManagerTests.cs | 201 + .../GrenadeCatalogTests.cs | 25 + .../HostExpressionFunctionsTests.cs | 669 +++ .../InventoryMaintenanceTests.cs | 225 + .../ItemManaRechargeTests.cs | 73 + .../LootingTests.cs | 1000 ++++ .../MetaEngineTests.cs | 286 ++ .../MetaViewManagerTests.cs | 202 + .../MonsterExpressionTests.cs | 164 + .../MossTankMarkupContractTests.cs | 262 ++ .../MossTankPanelTests.cs | 1255 ++++- .../NavigationTests.cs | 623 +++ .../PetAutomationTests.cs | 283 ++ .../ProfileGiveControllerTests.cs | 230 + .../VitalRechargeTests.cs | 397 ++ .../VtankAmmunitionDatabaseTests.cs | 134 + .../VtankDamageDatabaseTests.cs | 58 + .../VtankLootProfileSerializerTests.cs | 118 + .../VtankMetaProfileSerializerTests.cs | 190 + .../VtankNavRouteSerializerTests.cs | 199 + .../Gameplay/PlayerMouseLookMovementTests.cs | 57 + .../Gameplay/RuntimeActionStateTests.cs | 27 + .../Gameplay/RuntimeCombatAttackStateTests.cs | 21 + .../Gameplay/RuntimeCombatModeStateTests.cs | 18 + .../RuntimeHostileTargetQueryTests.cs | 38 + ...RuntimeInteractionTransactionStateTests.cs | 64 + .../RuntimeLocalPlayerMovementStateTests.cs | 34 + .../Gameplay/RuntimeSpellCastStateTests.cs | 34 + .../Session/LiveSessionControllerTests.cs | 39 + .../Panels/Chat/ChatCommandRouterTests.cs | 32 + tools/cdb/i451-dual-client-av.cdb | 10 + 212 files changed, 49462 insertions(+), 416 deletions(-) create mode 100644 docs/plans/2026-08-26-mosstank-parity-campaign.md create mode 100644 docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md create mode 100644 docs/superpowers/specs/2026-08-26-mosstank-autocombat-design.md create mode 100644 src/AcDream.App/Platform/Win32GlfwActiveWindowGuard.cs create mode 100644 src/AcDream.App/Plugins/FilePluginStorage.cs create mode 100644 src/AcDream.App/Plugins/LocalPluginPeerRegistry.cs create mode 100644 src/AcDream.App/UI/Layout/ProjectileDebugOverlayController.cs create mode 100644 src/AcDream.App/UI/PluginSidePanel.cs create mode 100644 src/AcDream.App/UI/UiMarkupList.cs create mode 100644 src/AcDream.App/UI/UiMarkupTabButton.cs create mode 100644 src/AcDream.App/UI/UiMarkupToggle.cs create mode 100644 src/AcDream.Core/Plugins/PluginCommandRegistry.cs create mode 100644 src/AcDream.Core/Plugins/PluginLootClassifierRegistry.cs create mode 100644 src/AcDream.Plugin.Abstractions/CombatAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/EnchantmentAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/EquipmentAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/FellowshipAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/IPluginStorage.cs create mode 100644 src/AcDream.Plugin.Abstractions/ItemAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/LoginAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/LootAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/LootClassifierPlugins.cs create mode 100644 src/AcDream.Plugin.Abstractions/MagicAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/NavigationAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/NetworkAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/PluginCommands.cs create mode 100644 src/AcDream.Plugin.Abstractions/ProjectileAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/RecoveryAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/SelectionAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/WorldObjectAutomation.cs create mode 100644 src/AcDream.Plugin.Abstractions/WorldTimeAutomation.cs create mode 100644 src/AcDream.Plugins.MossTank/AttackSpellCatalog.cs create mode 100644 src/AcDream.Plugins.MossTank/AutoAttackPower.cs create mode 100644 src/AcDream.Plugins.MossTank/CombatController.cs create mode 100644 src/AcDream.Plugins.MossTank/CombatFailureTracker.cs create mode 100644 src/AcDream.Plugins.MossTank/CombatItemDebuffPlanner.cs create mode 100644 src/AcDream.Plugins.MossTank/CombatSettings.cs create mode 100644 src/AcDream.Plugins.MossTank/Crafting.cs create mode 100644 src/AcDream.Plugins.MossTank/DebuffScheduler.cs create mode 100644 src/AcDream.Plugins.MossTank/DispelController.cs create mode 100644 src/AcDream.Plugins.MossTank/Expressions/CoreExpressionFunctions.cs create mode 100644 src/AcDream.Plugins.MossTank/Expressions/ExperienceMeter.cs create mode 100644 src/AcDream.Plugins.MossTank/Expressions/ExpressionEngine.cs create mode 100644 src/AcDream.Plugins.MossTank/Expressions/ExpressionRuntime.cs create mode 100644 src/AcDream.Plugins.MossTank/Expressions/ExpressionValue.cs create mode 100644 src/AcDream.Plugins.MossTank/Expressions/HostExpressionFunctions.cs create mode 100644 src/AcDream.Plugins.MossTank/Expressions/MossTankExpressionRuntime.cs create mode 100644 src/AcDream.Plugins.MossTank/Expressions/QuestTracker.cs create mode 100644 src/AcDream.Plugins.MossTank/Expressions/SalvageStagingManager.cs create mode 100644 src/AcDream.Plugins.MossTank/Expressions/StatusHudManager.cs create mode 100644 src/AcDream.Plugins.MossTank/FellowshipManager.cs create mode 100644 src/AcDream.Plugins.MossTank/GrenadeCatalog.cs create mode 100644 src/AcDream.Plugins.MossTank/InventoryMaintenance.cs create mode 100644 src/AcDream.Plugins.MossTank/ItemManaRecharge.cs create mode 100644 src/AcDream.Plugins.MossTank/Looting.cs create mode 100644 src/AcDream.Plugins.MossTank/Meta.cs create mode 100644 src/AcDream.Plugins.MossTank/MetaViewManager.cs create mode 100644 src/AcDream.Plugins.MossTank/MonsterExpression.cs create mode 100644 src/AcDream.Plugins.MossTank/MonsterRules.cs create mode 100644 src/AcDream.Plugins.MossTank/MossTankCommands.cs create mode 100644 src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs create mode 100644 src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs create mode 100644 src/AcDream.Plugins.MossTank/MossTankProfileRecovery.cs create mode 100644 src/AcDream.Plugins.MossTank/MossTankProfileStore.cs create mode 100644 src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs create mode 100644 src/AcDream.Plugins.MossTank/Navigation.cs create mode 100644 src/AcDream.Plugins.MossTank/PetAutomation.cs create mode 100644 src/AcDream.Plugins.MossTank/PetDeviceCatalog.cs create mode 100644 src/AcDream.Plugins.MossTank/ProfileGiveController.cs create mode 100644 src/AcDream.Plugins.MossTank/SpellComponentPolicy.cs create mode 100644 src/AcDream.Plugins.MossTank/VitalRecharge.cs create mode 100644 src/AcDream.Plugins.MossTank/VtankAmmunitionDatabase.cs create mode 100644 src/AcDream.Plugins.MossTank/VtankAmmunitionOptions.tsv create mode 100644 src/AcDream.Plugins.MossTank/VtankCraftDatabase.cs create mode 100644 src/AcDream.Plugins.MossTank/VtankCraftRecipes.tsv create mode 100644 src/AcDream.Plugins.MossTank/VtankDamageDatabase.cs create mode 100644 src/AcDream.Plugins.MossTank/VtankLootProfileSerializer.cs create mode 100644 src/AcDream.Plugins.MossTank/VtankLootRequirementEvaluator.cs create mode 100644 src/AcDream.Plugins.MossTank/VtankMetaProfileSerializer.cs create mode 100644 src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs create mode 100644 src/AcDream.Plugins.MossTank/VtankOptionCatalog.cs delete mode 100644 src/AcDream.Plugins.MossTank/mosstank-settings.xml create mode 100644 tests/AcDream.App.Tests/Platform/Win32GlfwActiveWindowGuardTests.cs create mode 100644 tests/AcDream.App.Tests/Plugins/AppAutomationSurfaceTests.cs create mode 100644 tests/AcDream.App.Tests/Plugins/FilePluginStorageTests.cs create mode 100644 tests/AcDream.App.Tests/Plugins/LocalPluginPeerRegistryTests.cs create mode 100644 tests/AcDream.App.Tests/UI/Layout/ProjectileDebugOverlayControllerTests.cs create mode 100644 tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs create mode 100644 tests/AcDream.Core.Tests/Plugins/PluginCommandRegistryTests.cs create mode 100644 tests/AcDream.Core.Tests/Plugins/PluginLootClassifierRegistryTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/AttackSpellCatalogTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/AutoAttackPowerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/CombatControllerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/CombatFailureTrackerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/CombatItemDebuffPlannerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/CraftingTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/DebuffSchedulerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/DispelControllerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/ExpressionEngineTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/FellowshipManagerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/GrenadeCatalogTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/HostExpressionFunctionsTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/InventoryMaintenanceTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/ItemManaRechargeTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/LootingTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/MetaEngineTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/MetaViewManagerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/MonsterExpressionTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/MossTankMarkupContractTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/NavigationTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/PetAutomationTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/ProfileGiveControllerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/VitalRechargeTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/VtankAmmunitionDatabaseTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/VtankDamageDatabaseTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/VtankLootProfileSerializerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/VtankMetaProfileSerializerTests.cs create mode 100644 tests/AcDream.Plugins.MossTank.Tests/VtankNavRouteSerializerTests.cs create mode 100644 tools/cdb/i451-dual-client-av.cdb diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 199cd8da..2b031850 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,79 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #451 — GLFW can dereference another acdream process's private window pointer after cross-process activation + +**Status:** IN-PROGRESS — exact root fixed; 100-switch/30-minute dual-client +stress passed 2026-08-27. Graceful exit of both sessions remains before closure. +**Component:** graphical host / GLFW Win32 event pump / multi-process stability. +**Severity:** HIGH for multi-account play; one of the two sessions is lost without +an orderly disconnect. + +Running two copies of the exact isolated `app-release23` graphical artifact +against local ACE reproduced the same access violation three times. The +faulting process varied: secondary PID 13688 at 15:01:39, primary PID 15300 at +15:03:35, and fresh primary PID 31412 at 15:08:58. The last occurrence fired +while the fresh primary was still on character selection, before EnterWorld; +the already-in-world secondary survived. Windows Application Error reports +all three as `coreclr.dll` exception `0xC0000005`, fault offset `0x356d4f`. +The managed terminal stack is only: + +```text +Silk.NET.Windowing.WindowExtensions...Run +Silk.NET.Windowing.Internals.ViewImplementationBase.Run +Silk.NET.Windowing.Glfw.GlfwWindow.Run +AcDream.App.Rendering.GameWindow.Run +``` + +This is not #422's rare `0xC0000374` heap corruption during graceful process +exit: #451 happens while both graphical clients are active and reproduces +quickly. It is also not a MossTank/plugin-API, CoreCLR, Vulkan, PAK, or world- +cache failure. + +**Exact root (first-chance cdb proof):** the access violation is in packaged +GLFW's Win32 event pump at `glfw3+0x10681`, not in CoreCLR. During its modifier- +key repair pass `_glfwPollEventsWin32` calls `GetActiveWindow`, then +`GetPropW(hwnd, L"GLFW")`, and dereferences the returned value as this +process's `_GLFWwindow*`. Windows UI automation temporarily joins input queues, +so the primary process can receive the secondary process's HWND. Because every +GLFW process uses the same `GLFW` property name, `GetPropW` succeeds but returns +the secondary process's private pointer. The crashed primary had +`rbx=00000202a7180ab0`; a debugger breakpoint in the surviving secondary +reported its own valid `ACTIVE_GLFW_WINDOW=00000202a7180ab0` — exact pointer +identity across the process boundary. + +**Fix:** `Win32GlfwActiveWindowGuard` patches only `glfw3.dll`'s import-address- +table slot for `USER32!GetActiveWindow`, after the GLFW library is loaded and +before `glfwInit`/window creation. The replacement returns the real HWND only +when `GetWindowThreadProcessId` says it belongs to the current process; +otherwise it returns zero, GLFW's existing safe "nothing to repair" branch. +There is no system-wide hook and no other module is changed. Four focused +tests cover local, foreign, null and unowned HWNDs. + +The isolated `app-release24` live gate launched two graphical clients, both +logged `GLFW foreign-active-window guard installed (#451)`, entered the world, +and remained responsive through 100 rapid forced cross-process activation +switches — the exact prior trigger — plus a 30-minute combined in-world soak. +The local peer API then passed in both directions: the secondary evaluated the +primary heartbeat and returned `+Acdream`. A two-member fellowship gate also +passed with both canonical rosters populated and the secondary returning `2` +from `getfellowshipcount[]`; both processes remained alive and responsive. + +Evidence: + +- `artifacts/live-gates/mosstank-final23-secondary/` +- `artifacts/live-gates/mosstank-final23-secondary2/` +- `artifacts/live-gates/mosstank-final23-primary5/` +- `artifacts/live-gates/i451-cdb-primary-attach/cdb.log` +- `artifacts/live-gates/i451-cdb-secondary/cdb.log` +- `artifacts/live-gates/i451-guard-primary2/` +- `artifacts/live-gates/i451-guard-secondary/` +- Windows Application Error events at 2026-08-27 15:01:39, 15:03:35 and + 15:08:58 (same module, exception and offset). + +**Next:** close only after both `app-release24` sessions exit gracefully; the +activation and sustained in-world portions of the regression gate have passed. + ## #450 — Fast character re-entry after logout can remain in portal space at `lb 0/0` **Status:** DONE — OWNER-ACCEPTED 2026-08-26 in the combined client-parity gate. diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 9487b18b..f7bb3adb 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -137,6 +137,68 @@ loads none). The headless adapter projects entity snapshots on demand from the canonical Runtime view, subscribes to Runtime's ordered events, and borrows the exact Runtime selection owner; it does not mirror gameplay state. +Graphical plugin panels are first-class retained windows. A plugin calls +`IUiRegistry.AddPanel` with a BCL-only `PluginPanelDescriptor`; Core's scoped +host authenticates the owner from the loaded manifest, and App derives the +stable identity `plugin:{pluginId}:{windowId}`. The host, not the plugin, owns +window geometry, z-order, persisted visibility, minimize/restore chrome, and +the shared right-edge plugin shelf. Minimizing only hides the presentation: +the plugin session, event subscriptions, automation policy, and binding object +remain live. Legacy `AddMarkupPanel` registrations are enriched into the same +first-class path, so API-v1 plugins keep working without a second lifecycle. +The markup vocabulary includes nested groups plus retained tab, toggle, +slider, editable-field, and retail-menu controls. Fields bind live +`Action` change/submit callbacks and menus bind an +`IEnumerable` plus selection callback, so plugin-owned profile/rule +editors stay behind the BCL contract instead of importing App widgets. These +are presentation bindings only and never become parallel gameplay owners. + +Durable plugin data uses the BCL-only `IPluginHost.Storage` contract. Core's +manifest-authenticated scoped host prefixes every logical key with the loaded +plugin id; graphical composition writes atomically beneath the per-user config +root (`plugins/{pluginId}/...`). Plugins receive neither another plugin's +namespace nor a machine-specific path. Hosts without durable storage expose +`NoOpPluginStorage` and report the capability unavailable. +The additive `List(prefix)` operation enumerates only keys inside that same +authenticated namespace, allowing plugins to discover explicit import/export +files without receiving a filesystem path or crossing plugin ownership. + +`IPluginHost.Automation` is the additive gameplay-automation projection. Its +character, spell, magic, chat, combat, equipment, item, loot, fellowship, +enchantment-observation, and navigation +groups contain BCL-only immutable snapshots plus attempt-style commands; the +graphical implementation borrows the exact `GameRuntime` +character/action/entity/object/vendor/fellowship owners. Item projections also +carry Virindi's stable ObjectClass plus ordered ObjDesc subpalette samples; +the graphical host resolves each representative RGB directly from portal DAT +using VTank's sample-index formula. MossTank owns all +macro policy (buff planning, target rules, selection scoring, corpse policy, +loot-rule ordering and action timing). In particular, `ICombatAutomation` +does not create a plugin combat model: each hostile capture is a detached +point-in-time projection of `RuntimeHostileTargetQuery`, and physical commands +enter the canonical `RuntimeCombatModeState` / `RuntimeCombatAttackState` +press-charge-release state machine. Item and loot commands similarly enter +App's one `ItemInteractionController`: appraisal, use/apply, pickup, +move/split/merge/drop/give, retail 0x027D salvage, and current-vendor sale +reuse the same readiness checks, reservations, wire sends, and authoritative +completion/object-table signals as retained retail UI. Plugins never hold an +optimistic inventory or vendor shadow. Navigation similarly projects live and +server-accepted position, portal/object state, and semantic movement levels; +the App host applies those levels through Runtime's one command interpreter. +Route sequencing, steering cones, follow breadcrumbs, checkpoint policy, +door/lockpick decisions and portal retry behavior remain plugin-owned. The +shared enchantment-observation group is deliberately a confirmed-cast timer +ledger rather than another authoritative spellbook: the host records successful +local duration casts and cooperating plugins can report their own confirmed +casts, matching VTank's `LogSpellCast` contract. It resets at session detach; +dispel/debuff policy remains plugin-owned. The +inert default remains +`NoOpAutomationSurface`, preserving one plugin code path on hosts without a +live gameplay session. +`ICharacterInfo.Name` projects the canonical local `ClientObject.Name` (empty +when unavailable) solely for per-character plugin profile scoping; it does not +introduce a second identity owner. + Core `SelectionState` is the sole selected-object owner for world, radar, inventory, paperdoll, toolbar, use/examine consumers, and plugins; `IPluginHost.Selection` exposes that same state and retail-style old/new callback. @@ -420,6 +482,10 @@ src/ IGameState.cs -> done IEvents.cs -> done ISelectionService.cs -> done + IPluginStorage.cs -> manifest-scoped durable text profiles + Automation.cs -> character/spell/magic/chat automation groups + CombatAutomation.cs -> hostile snapshots + retail combat attempts + EnchantmentAutomation.cs -> shared confirmed duration-cast timer ledger AcDream.App/ Layer 1 + Layer 4 wiring Platform/ diff --git a/docs/launch-options.md b/docs/launch-options.md index 109ebc76..0d6178ec 100644 --- a/docs/launch-options.md +++ b/docs/launch-options.md @@ -98,6 +98,7 @@ dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release | `ACDREAM_NEAR_RADIUS` | `=` | Overrides preset's `NearRadius` (inner streaming ring radius) | Changes the streaming/render window size — CLAUDE.md: leave unset for measurement/gate runs | preset's `NearRadius` (Low=2, Medium=3, High=4, Ultra=5) | `QualitySettings.WithEnvOverrides` (`QualityPreset.cs:46`) | | `ACDREAM_NO_AUDIO` | `=1` | Suppresses OpenAL device init entirely (headless / driver-broken machines). | Fail-open design: even without this flag, a missing/broken OpenAL driver already makes `IsAvailable=false` and all `Play*` calls no-ops — this flag is the explicit, deliberate version of the same fallback. When set, `LiveSessionWorldRuntime.WorldAudio` is `null` and logout-audio reset/resume steps no-op. | `false` | `RuntimeOptions.NoAudio` → `GameWindow.cs:1430` → `ContentEffectsAudioCompositionPhase` → `OpenAlAudioEngine.cs` (fail-open doc), `LiveSessionRuntimeFactory.cs:71` (`WorldAudio` gate) | | `ACDREAM_PAK_PATH` | `=` | Overrides the path to the prepared machine-local asset bake (`acdream.pak`) that production world-mesh streaming reads through `IPreparedAssetSource`. | none beyond the obvious | unset → `/acdream.pak` | `RuntimeOptions.PreparedAssetPath` → `ContentEffectsAudioComposition.cs:53,372,379`, `GameWindow.cs:1420` | +| `ACDREAM_PLUGIN_TAGS` | comma-separated tags (maximum 128 tags, 128 characters each) | Advertises machine-local role/group tags through the plugin peer-discovery API, for UtilityBelt-compatible expressions such as client selection by tag. Values are trimmed and deduplicated case-insensitively. | Writes the tags into the bounded local peer heartbeat document while a character is in world; no network traffic leaves the machine. | unset → no tags | `RuntimeOptions.PluginTags` → `AppAutomationSurface` / `LocalPluginPeerRegistry` | | `ACDREAM_RESIDENCY_ALPHA_SCRATCH_MIB` | `=` (`>0`, else default; overflow-checked) | Byte ceiling for the retail alpha (translucency) draw queue's scratch buffer | Shrinking below production working set changes translucency-queue eviction/reflow behavior — not comparable to a default-budget perf run | 16 MiB | `ResidencyBudgetOptions.Parse` (`ResidencyBudgetOptions.cs:88-89`), flows through `RuntimeOptions.ResidencyBudgets`→`AlphaScratchBudgetProfile.Create`→`RetailAlphaQueue` ctor (`GameWindow.cs:721-725`) | | `ACDREAM_RESIDENCY_ANIMATION_ENTRIES` | `=` (`>0`, else default) | Entry-count ceiling for the retained animation-data cache | Changes cache eviction cadence for animation data — a perf/memory measurement under a non-default value is not representative | 512 | `ResidencyBudgetOptions.Parse` (`:82-84`) | | `ACDREAM_RESIDENCY_ANIMATION_MIB` | `=` (`>0`, else default) | Byte ceiling for the retained animation-data cache | Same cache-behavior-change caveat as the entries variant | 64 MiB | `ResidencyBudgetOptions.Parse` (`:79-81`) | diff --git a/docs/plans/2026-04-24-ui-framework.md b/docs/plans/2026-04-24-ui-framework.md index a216406e..0a82d8a8 100644 --- a/docs/plans/2026-04-24-ui-framework.md +++ b/docs/plans/2026-04-24-ui-framework.md @@ -184,17 +184,28 @@ panel through `IPanelRenderer`. ## Plugin UI API -The shipped plugin-facing gameplay UI contract is -`AcDream.Plugin.Abstractions.IUiRegistry.AddMarkupPanel`: a plugin provides -KSML-style markup and a binding object; the host builds it into the retained -`UiRoot` tree. `IPanel`/`IPanelRenderer` remains a first-party developer-panel -contract and is intentionally not referenced by `Plugin.Abstractions`. +The shipped plugin-facing gameplay UI contract is the additive BCL-only +`AcDream.Plugin.Abstractions.IUiRegistry.AddPanel`: a plugin provides a stable +window id/title/icon descriptor, KSML-style markup, and a binding object; the +host builds it into the retained `UiRoot` tree. The API-v1 +`AddMarkupPanel` member remains source/binary compatible and is enriched into +the same first-class window route by the scoped host. `IPanel`/ +`IPanelRenderer` remains a historical first-party developer-panel contract and +is intentionally not referenced by `Plugin.Abstractions`. -This makes plugin gameplay panels independent of ImGui while allowing them to -share the retained input, window, and DAT-sprite runtime. Registrations made -before the GL host exists are buffered. In builds where retail UI is disabled, -they remain registered but have no gameplay surface; the long-term release -configuration enables retained gameplay UI. +This makes plugin gameplay panels presentation-assembly independent while +allowing them to share the retained input, window, and DAT-sprite runtime. +Registrations made before the graphical host exists are buffered. The host +assigns `plugin:{pluginId}:{windowId}`, registers every panel with the common +window manager, persists its geometry/visibility, and exposes it through the +shared plugin sidepanel. Hiding/minimizing a panel does not dispose or pause the +plugin. No-window hosts retain the plugin session but expose the no-op UI +capability. + +The retained markup vocabulary includes panels, nested groups, labels, +buttons, meters, tabs, lamp-style toggles, and scalar sliders. Controls bind to +BCL-visible properties/actions on the plugin binding object; visible controls +must correspond to real behavior, never placeholders that report success. The following was the original pre-D.2b proposal and remains historical context, not the shipped plugin contract: @@ -255,7 +266,8 @@ walk around / take damage / regen. ### Sprint 3 — Plugin API hardening (superseded shape) - Document the `IPanel` contract. -- The shipped route is `IUiRegistry.AddMarkupPanel`, not plugin-owned +- The shipped route is `IUiRegistry.AddPanel` (with `AddMarkupPanel` as the + compatible legacy entry), not plugin-owned `IPanel` implementations. - Confirm plugins can subscribe to game events and expose retained markup bindings without referencing App or ImGui assemblies. diff --git a/docs/plans/2026-08-26-mosstank-parity-campaign.md b/docs/plans/2026-08-26-mosstank-parity-campaign.md new file mode 100644 index 00000000..2ac3576e --- /dev/null +++ b/docs/plans/2026-08-26-mosstank-parity-campaign.md @@ -0,0 +1,483 @@ +# MossTank — VTank parity campaign + +Date: 2026-08-26 +Status: ACTIVE — MT1 USER-PASSED; MTUI–MT9 functional/API scope complete; connected shelf/shell, accessibility, reconnect, bidirectional peer-expression and two-member fellowship gates passed; #451 root fixed and 30-minute dual-client activation soak passed; hostile/collision gates remain + +Research baseline: +`docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md` + +## Product definition + +MossTank will provide the complete automation capability associated with +Virindi Tank, implemented as a first-class acdream plugin over a stable, +BCL-only plugin API. UtilityBelt's typed expression dialect is the scripting +baseline. Native file formats may differ; behavior and extensibility may not. +The finished surface is a visually verbatim VTank reproduction: every VTank +tab and function is present and every enabled control invokes real behavior. + +## Non-negotiable boundaries + +- modern code, behavior matched to documented VTank/retail behavior; +- one Runtime owner for every state/action; plugin API is a borrowed projection; +- policy engines remain in MossTank, not App or Runtime; +- plugin UI only through `IUiRegistry`; +- every API addition works in graphical and no-window hosts, with explicit + unavailable behavior until the host can genuinely supply it; +- no fake success and no silent expression-function omission. + +## Slice ledger + +### MT0 — research and campaign design + +- [x] Reconcile existing VTank audit with current Runtime ownership. +- [x] Audit current UtilityBelt grammar and all 260 expression declarations. +- [x] Define complete capability ledger and staged architecture. + +### MT1 — autocombat foundation (current stop gate) + +- [x] Add target/combat views and attempt commands to the plugin API. +- [x] Project canonical hostile, selection, mode, power and spell state. +- [x] Implement target lock and range/angle/hybrid selection. +- [x] Implement melee/missile charge-release and direct offensive magic. +- [x] Deliver the polished combat dashboard and settings. +- [x] Focused, App/Runtime and complete solution gates. +- [x] Connected user gate: user confirmed autocombat works in the plugin. + +MT1 intentionally does not pretend later features exist. It is “autocombat +ported,” not “all combat policy ported.” + +### MTUI — generic plugin-window and VTank shell foundation + +- [x] Add manifest-authenticated, stable plugin panel descriptors without + breaking API-v1 hosts/plugins. +- [x] Register plugin panels with the common retained window manager so + geometry and visibility persist. +- [x] Add the shared right-edge plugin shelf and window minimize/restore; + hidden panels leave the plugin session and automation running. +- [x] Add reusable nested groups, tabs, lamp toggles and sliders to retained + plugin markup. +- [x] Replace MossTank's dashboard/settings pair with one VTank-shaped shell + using the exact Options, Profiles, Vitals, Monsters, Items, Consumables, + Buffs, Route, Meta tab order. +- [x] Bind all currently enabled controls to real MT1/buff behavior and leave + unimplemented tabs visibly disabled. +- [x] Enable the Items/Consumables pages against durable, manifest-scoped + exact-name profiles; selection and Add/Add-no-buffs/Add-All-Peas controls + all mutate the policy consumed by combat. +- [x] Connected visual gate: shelf placement, minimize/restore persistence, + and first VTank-shell comparison in the live client. + +### MT2 — complete monster/weapon/debuff combat policy + +- [x] ordered `DEFAULT` + first-match monster rules; +- [x] priorities -1..4 and complete action-flag matrix; +- [x] damage/weapon/offhand selection, swap state machine and auto power, + including the official GameInfoDB exact-name overrides, ordered creature- + species preferences, and VTank's final elemental fallback; +- [x] debuff groups, skill/level choice, receipt-gated reapply and explicit + wand switching policy; +- [x] ring/arc/bolt density/range logic, streaks, Void, harm/martyr, grenades, + lenses, cast-on-strike and pets; + (carried phials are complete; crafting a missing phial belongs to MT4's + generalized craft transaction); +- [x] blacklist and both ghost-monster detectors, including canonical App + entity teardown for a detected client ghost. + +MT2 checkpoint 2026-08-27: the BCL API now projects complete learned-combat +spell metadata, server cast and physical-attack receipts, health-update +revision/age, canonical equipment snapshots/commands, and exact-incarnation +ghost deletion. MossTank owns the complete Monsters expression/action model, +debuff tracker, elemental/shape spell catalog, range/density selection, +weapon/offhand policy, temporary blacklist, and both VTank ghost algorithms. +Focused evidence at this checkpoint: 104 MossTank tests, 20 Runtime action/ +target tests, and an isolated Release App build all pass with zero failures or +warnings. MT2 remains open for automatic physical power and the four item- +backed combat families. + +MT2 checkpoint 2 (2026-08-27): the official VTank assembly and live GameInfoDB +feed were inspected directly. Item appraisal SpellBooks are now retained; +plugins receive ordered combat chat and exact item UseDone receipts; the +source planner implements `dz.b.CompareTo` for SpellLevel/Skill preference; +the 72 official phials, lenses, cast-on-strike weapons and pets are executable +and profile-gated; proc success waits for the actual `You cast ... on ...` +line. `hi.cs` automatic attack power, including Recklessness clamping, is +ported verbatim. Items/Consumables profiles are atomically persisted through a +new per-manifest plugin-storage contract. Focused evidence: 131 MossTank tests, +the storage/chat/App tests, and an isolated Release App build pass. MT2 remains +open only for target-database `Auto` damage selection and the connected gate; +missing-grenade crafting is deliberately MT4 transaction scope. + +MT2 automated closeout (2026-08-27): `Auto` now consumes the official 59-name +override and 103-species preference tables. The ordered element decision +outranks spell shape/tier, drives profiled physical weapon selection, and feeds +automatic attack power and vulnerability policy. Unknown targets preserve +VTank's final Pierce→Bludgeon→Slash→Acid→Lightning→Cold→Fire fallback. Focused +evidence after the closeout and named-profile foundation: 146 MossTank tests; +isolated Release App build 0 warnings / 0 errors. The connected MT2 combat +matrix remains part of the later combined user gate. + +### MT3 — buff, heal and resource parity + +- [x] named macro profiles, buff exclusions/item buffs/top-off foundation; +- [x] all three vital threshold tiers and canonical fellowship vitals; +- [x] profiled kits/consumables and worn-item mana recharge; +- [x] VTank ManaStone/ManaTank acquisition and exact-receipt fill behavior; +- [x] conversions, self/item/fellow dispel response, and critical/normal/idle + component plus six-category consumable upkeep. + +### MT4 — inventory, craft and transactions + +- [x] AutoStack/AutoCram and the official 757-row VTank craft database; +- [x] generalized use/apply/give/move/split/stack/drop transaction API with + receipts and busy arbitration; +- [x] retail 0x027D salvage and authoritative current-vendor sale paths; +- [x] same-input authoritative split crafting, all three split priorities and + exact VTank door/lockpick policy. + +### MT5 — looting and extensible rule engine + +- [x] corpse lifecycle/ID waits, exact 30-attempt/200-second open blacklist, + 60-minute cache, 100-second public ownership, fellow Share Loot and rare-only + policy; +- [x] ordered first-match raw/projected-property expressions plus Keep, + KeepUpTo, Read, Salvage, Sell, ManaStone, ManaTank and User1–User5; +- [x] canonical appraisal/pickup/salvage/vendor seams, unknown-scroll fallback, + and exact VTank salvage workmanship bands with 40-attempt abandonment; +- [x] independent By-char and named native loot profile documents; +- [x] exact VTClassic `.utl` v0/v1 importer/exporter, every structured + requirement, forward-compatible length blocks, and profile-owned salvage + ranges/value modes; +- [x] external loot-classifier plugin capability. + +MT5 functional closeout (2026-08-27): the graphical host now exposes corpse +discovery, raw item properties, canonical appraisal/pickup, learned-spell +membership, fellowship Share Loot, retail salvage (0x027D), and current-vendor +sale through additive BCL-only interfaces. MossTank owns all policy and waits +for authoritative receipts/object removal; no action reports success at +dispatch. The official VTank corpse timers, rare/fellow ownership branches, +unknown-scroll difficulty check, mana-stone pairing, salvage-bag workmanship +bands, and bugged-bag retry ceiling were ported from the official decompiled +source. Focused evidence: 184 MossTank tests, 21 inventory-wire/session tests, +134 App automation/item/UI tests, and an isolated Release App build with zero +warnings/errors. File interoperability remains an MT9 compatibility tail, not +a reason to hold Route/Navigation. + +### MT6 — navigation + +- [x] canonical move/follow/turn/charged-jump/checkpoint host primitives; +- [x] circular, linear, once and Target/follow routes, including VTank's + endpoint reversal, destructive Once traversal and follow-around-corners; +- [x] every decoded nav node (0..9), closed-door/lockpick policy, vendor and + repeated NPC use, portal re-entry protection and combat/nav priority; +- [x] independent By-char and named native route profiles; +- [x] exact `uTank2 NAV 1.2` importer/exporter. + +MT6 functional closeout (2026-08-27): the additive navigation API projects +VTank coordinates, live and server-accepted player position, object +reacquisition, door state, portal state, and typed movement levels through the +one Runtime command interpreter. MossTank owns the exact four route modes and +ten node types. Steering ports `fd.cs`'s 4° turn threshold, far 45° and near +15° forward cones; checkpoints use `gr.cs`'s accepted-position gate and +15-second nudge; Target mode ports `gl.cs` breadcrumb pruning; doors port +`b7.cs`'s defaults (disabled, 20 m ID, 4 m open, −50 lockpick threshold). +Portal2/UseNPC reacquire exact-name objects near the saved point, NPC use waits +for tell/give chat, jumps align to their stored heading before charge/release, +and Once removes completed rows exactly like VTank. Evidence: 204 MossTank +tests, focused App navigation projection tests, and isolated Release App build +with zero warnings/errors. Legacy file interop remains an MT9 compatibility +tail and does not hold the expression engine. + +### MT7 — expressions + +- [x] immutable AST, typed values, budgets and diagnostics; +- [x] UtilityBelt grammar semantics including lists/dicts/slices; +- [x] implement/alias/explicitly disposition the 260-function audit ledger; +- [x] VTank option/expression command diagnostics; +- [x] parser, evaluator, persistence and capability-security gates. + +### MT8 — meta engine and runtime views + +- [x] complete condition/action vocabulary, nested composition, once-per-entry, + call/return and watchdog; +- [x] chat capture variables and option access; +- [x] plugin-authored runtime views over the retained markup contract; +- [x] native meta profile; +- [x] exact VTank CondAct `.met` importer/exporter, including recursive rules, + embedded NAV and the historical CreateView record quirk. + +### MT9 — fellowship, profiles, commands and polish + +- [x] tell-driven recruitment, waiting-list, status/location commands, and + two-minute kick/ban/giveleader/setopen voting over canonical fellowship + commands; +- [x] helper healing, fellowship corpse permissions and shared target views; +- [x] macro-profile foundation: true per-character `By char` documents, named + create/copy/clear/select, mine-only filtering, hot loading, atomic manifest- + scoped storage, and complete current combat/buff/vitals/monster/item state; +- [x] independent navigation/loot/meta profile documents remain with MT5/MT6/MT8; +- [x] exact 137-name typed VTank option catalog/defaults and durable + `/vt opt setinall` across every indexed named/character macro profile; +- [x] all documented `/vt` command names are locally registered and handled; +- [x] exact `.nav`, `.met`, and `.utl` dumps/import-export; +- [x] privileged debug-operation semantics (`clearlocks`, `clearbusy`, + `fakeimp`) use canonical owners and authoritative lifetime cleanup; +- [x] first-run guidance, native/VTank profile migration and corrupt-profile + recovery with append-only raw-data preservation; +- [x] accessibility and scaling polish; +- [ ] performance soak, reconnect/lifecycle and multi-client gates. + +## MT1 execution order + +1. Add BCL-only combat records/interfaces with inert defaults. +2. Extend Runtime hostile query with exact position/heading snapshots. +3. Bind App's automation surface to the canonical action/spell owners. +4. Implement/test MossTank's deterministic combat controller. +5. Replace the small panel with dashboard/settings markup and generic markup + affordances needed by the design. +6. Run narrow tests, Release build, broad tests; record exact evidence here. + +## Closeout evidence + +MT1 code-complete 2026-08-26 and user-passed 2026-08-27. The additive BCL-only contract is +`CombatAutomation.cs`; older API-v1 implementations retain inert default +members. `AppAutomationSurface` borrows the canonical Runtime owners and +projects hostile captures, combat state, physical press/release attempts, +targeted casting and learned direct offensive spells. MossTank's +`CombatController` owns priority, target lock, range/angle/both selection, +mode entry, power-bar timing and magic choice. The dashboard/settings markup +uses the retained plugin registry; generic markup now supports bound child +visibility/enabled state and button colors. + +Automated evidence: + +- focused MossTank: 54 passed / 0 failed; +- complete Runtime: 1,854 passed / 0 failed; +- repository-owned hermetic Release gate: **15,775 passed / 0 skipped / + 0 failed across 14 assemblies**; +- Release build: 0 warnings / 0 errors; +- the original MossTank XML documents parsed successfully before the gate. + +MTUI code-complete 2026-08-27. `PluginPanelDescriptor` and authenticated +`PluginUiOwner` carry presentation metadata through Core's transactional +plugin lifetime; App mounts the stable panel as a `RetailWindowHandle` and the +generic `PluginSidePanel` owns only hide/restore UI. The one-window MossTank +shell uses real retained tabs/toggles/sliders. Focused evidence: 18 App/plugin +tests and 56 MossTank tests passed; isolated Release App build passed with +0 warnings / 0 errors. Broader hermetic evidence: Core 4,720/4,720 and Runtime +1,854/1,854 passed; App passed 6,441/6,442 with the sole failure in the +unrelated pre-existing landblock recenter assertion +`OriginRecenter_RetryPreservesLiveIdentityAndDoesNotRescueReusedGuid`. Its +connected visual gate remains open. + +MT3/MT4 resource closeout 2026-08-27: crafting now runs through VTank's three +ordered tiers: critical component/consumable recovery, normal component and +general profile crafting, then no-target idle component and six-category +kit/food stock targets. Same-input recipes wait for both the authoritative +split receipt and publication of two distinct stacks before applying. The +official `IdleCraftCount_*` underscore names, 4/20/20 component defaults, and +2/2/2 kit plus 15/15/15 food targets persist in named/By-char profiles. +Self-cast and item dispels port `c8.cs`/`cx.cs`; fellowship Awakener selection +ports `af.cs`, including exact training, Arcane Lore, 5 m, spell-3179 and +summed-vulnerability-quality gates. The additive shared duration-spell ledger +matches VTank's confirmed local/external `LogSpellCast` model and clears on +session detach. Evidence: 277/277 MossTank tests, 12/12 focused App automation +tests, and isolated Release App build with zero warnings/errors. + +MT7–MT9 checkpoint 2026-08-27: MossTank registers all 260 audited +UtilityBelt public expression names over the typed evaluator, and the Meta +runtime/editor, dynamic views, embedded routes, command execution and durable +variable scopes are integrated. The host now provides an unload-safe generic +plugin-command registry; `/vt` follows the same local command route from typed +chat, launcher login commands and no-window clients. The exact official +four-line command catalog and 137-row typed option database are present; +`setinall` rewrites every indexed named/character profile. Run Macro is now a +master lifecycle distinct from Enable Combat, and command jumps align before +charging. The additive fellowship API projects the canonical retail commands; +MossTank owns VTank's tell commands, wait list, spam limit, near-player +recruitment, leader transition cleanup and two-minute voting. Evidence at this +checkpoint: 261/261 MossTank tests, 18/18 runnable focused App/plugin tests, +and isolated Release App build with zero warnings/errors. Four additional +GraphicalPluginSession tests could not locate the repository when deliberately +run from an isolated OutputPath; this is test-harness path behavior, not a +product failure. Connected shelf/UI/fellowship and combined automation gates +remain open. + +Legacy-profile checkpoint 2026-08-27: native JSON remains MossTank's durable +working format, while every save also emits a genuine VTank compatibility +file. `uTank2 NAV 1.2` routes and CondAct `.met` files round-trip exactly; +the Meta writer was independently accepted and canonicalized byte-identically +by the public `metaf` reference compiler. VTClassic `.utl` v0/v1 now retains +length-delimited unknown requirements/blocks, executes all 31 published +requirement types (including the DAT-resolved ordered-palette color family), +and applies per-material salvage ranges/value modes to the real 0x027D combine +planner. Native-only text rules export disabled rather than becoming +VTClassic's dangerous empty-requirement match-all. Evidence: 290/290 MossTank +tests, 13/13 focused App/plugin tests, and isolated Release App build with zero +warnings/errors. + +External-loot checkpoint 2026-08-27: the BCL-only host now owns an unload-safe +classifier registry. Classifier ids are namespaced to the registering plugin, +all registrations are disposed transactionally with that plugin's session, +and exceptions are isolated at the registry boundary. MossTank exposes the +available engines in Profiles, persists the selection with the macro profile, +and runs Keep/KeepUpTo/Read/Salvage/Sell/User1–User5 decisions through its +existing authoritative corpse executor. An unavailable engine never silently +changes policy by falling back to VTClassic. Evidence: 2 focused Core registry +tests, 55 focused MossTank loot/panel/markup tests, and isolated Release App +build with zero warnings/errors. + +Options/debug checkpoint 2026-08-27: the VTank Options page now uses the +verbatim four-column control arrangement. Normal automatic rebuff, the +separate idle top-off window, Attack→Approach distance navigation, and final +Idle Peace fallback were ported from `fz.cs`, `cLogic.cs`, `g8.cs`, `eb.cs` +and `cm.cs`; Force Buff and Cancel Force Buff remain distinct actions. The +Advanced Options button opens the full ordered 137-setting table. `/vt +clearbusy` decrements exactly one Runtime-owned inventory busy reference, +`clearlocks` clears only MossTank's transient policy locks, and `fakeimp` +records VTank's local 3,000-second Gossamer Flesh debug marker without forging +a server cast. External classifiers now receive authoritative `OnLooted` and +`OnItemRemoved` lifecycle callbacks after inventory publication. Evidence: +298/298 MossTank tests and an isolated Release App build with zero warnings +and zero errors. + +Final automated API/options checkpoint 2026-08-27: every one of the 137 +official advanced-option names has an explicit writable live-policy mapping; +the full catalog, official defaults, case-insensitive lookup and durable +profile propagation are covered. The Monsters page now exposes the three +distinct official cycles for Damage type, Ex. Vuln and PetDmg rather than one +shared internal enum. Prismatic remains an ammunition policy while preserving +automatic magic-element selection; Fists uses Tusker Fists only while its +enchantment is active. `DoJiggle` now ports VTank's PreviousSelection followed +by alternating NextPlayer/PreviousPlayer at 131 ms and no longer moves the +character. `ShowCollisionDebug` publishes bounded projectile samples through +the BCL-only API and renders transient red/green markers in the retained UI. +`WhoYouGonnaCall` is intentionally stored but inert, matching the official +source's explicit `No Function` disposition. + +The plugin API now projects combat, magic, equipment/items, looting, +fellowship, enchantments, navigation, world objects/time, login, network peer +state, recovery, projectile diagnostics and selection through canonical +Runtime/App owners. Startup peer tags are parsed once by `RuntimeOptions`, +portable data paths come from `ApplicationPathSet`, and both graphical and +headless plugin hosts load fixtures correctly from isolated output graphs. +Latest hermetic evidence: App 6,592 passed / 94 environment-dependent skips; +Runtime 1,863/1,863; Core 4,911/4,911; Core.Net 1,042/1,042; Headless +171/171; UI abstractions 880/880; MossTank 320/320 — **15,779 passed, zero +failed** across the selected automated lanes. The Release App build completed +with zero warnings and zero errors. Excluded gates are explicit: manual/live +lanes, Linux-only tests on this Windows host, the machine-local stale bake-tool +4 PAK test, and one registered pre-existing tower-ascent known failure. The +generic shelf, VTank shell, minimization-while-running, reconnect, live combat, +multi-client peer expressions, and collision-marker appearance remain owed in +the combined connected user gate. + +Connected shelf/shell gate 2026-08-27: the first isolated Release launch found +that App's plugin-copy target still assumed each plugin's conventional `bin` +directory when a custom `OutputPath` was active. That caused the packaged +MossTank DLL/markup to be stale even though the root build outputs were current. +Build and publish now resolve both first-party plugin targets through MSBuild's +`GetTargetPath`; MossTank markup copies directly from its source. The rebuilt +package's MossTank DLL and XML matched their build/source SHA-256 hashes and +the boundary regression passed 5/5. + +The next live launch exposed a retained-markup contract mismatch: one field +reused an `Action` button binding where `onsubmit` requires `Action`, +preventing the complete plugin window from mounting. MossTank now has a typed +submit action and its markup contract test validates every interactive binding's +delegate shape. A later visual pass also caught three unsupported inline label +bindings on Meta; all are now whole-value properties, and the contract rejects +future inline interpolation. Focused MossTank evidence is 321/321; isolated +Release build `app-release22` is zero-warning/zero-error with exact packaged +artifact hashes. + +The connected `app-release22` gate then passed: all nine tabs mounted and were +visually inspected; Meta rendered `State: Default`, `N: 0`, and `N2: 0`; the +right-edge `MT` shelf button was fully reachable; minimize hid only the window; +while hidden the live buff pass advanced from 91/97 to 77/97; restore showed +`Stop Macro` and the changed live status; the macro stopped normally. Logs show +92 server-confirmed `UseDone err=0` casts and no plugin/UI exception. Shift+Esc +completed the full logout presentation and returned to character selection. +This supersedes the earlier statement that the shelf, shell, minimization, and +basic reconnect/lifecycle presentation were wholly unproven. At that checkpoint, +still owed were +the accessibility/scale closeout, longer performance/reconnect soak, live +hostile combat matrix, two-client peer expressions/fellowship, and collision- +marker appearance. + +Accessibility/reconnect/peer checkpoint 2026-08-27: textless and terse controls +now carry runtime-bound retained tooltips, and the common window owner clamps +plugin panels to the current viewport (including the 800x600 oversize case). +Focused evidence is 325/325 MossTank tests, 16/16 retained-UI tooltip/geometry +tests, and isolated Release `app-release23` with zero warnings/errors. The live +client displayed the Monster Range help text, completed a same-character +logout/re-entry, restarted the macro, and completed another 92 server-confirmed +casts. Working/private memory stayed approximately 1.59/1.84 GiB across the +combined soak rather than climbing with casts or reconnect. + +The local peer API also passed real two-process expressions in both directions: +the secondary `+Horan` evaluated +`dictgetitem[listgetitem[netclients['mosstank-guard-primary'],0],'Name']` and +received `+Acdream`, while the earlier reciprocal gate returned `+Horan` to +the primary; both heartbeat documents contained the expected names, tags, +vitals and positions. + +That broader gate exposed separate client defect #451. First-chance cdb proof +located it in GLFW's Win32 event pump: temporary cross-process input-queue +attachment let `GetActiveWindow` return the other acdream process's HWND; +GLFW's shared `L"GLFW"` property then returned the other process's private +`_GLFWwindow*`, which the caller dereferenced. `app-release24` installs the +current-process HWND guard at GLFW's own import slot before `glfwInit`; its four +focused tests pass. Two rebuilt graphical clients then entered world, survived +100 rapid forced activation switches—the exact old trigger—and remained +responsive through a 30-minute combined soak with no native error. Issue #451 +remains in-progress only until both sessions complete a graceful-exit gate. + +The secondary-owned fellowship gate also passed: `+Acdream` created +`mosstankgate`, `+Horan` joined, both canonical rosters contained both members, +and the secondary evaluated `getfellowshipcount[]` as `2`. + +Still owed here: the hostile combat matrix and collision-marker appearance. + +Final local validation checkpoint 2026-08-27: the complete Release solution +build passed with zero warnings and zero errors. Focused MossTank passed +325/325 and the App plugin/API/UI/GLFW set passed 35/35. The conservative +Windows hermetic filter passed 15,083 non-network tests; Core.Net then passed +1,042/1,042 in its isolated lane, for 16,125 passing selected tests. The first +max-parallel combined invocation made Core.Net's timing-sensitive two-percent +packet-loss soak exhaust its wall-clock headroom; the same case and complete +Core.Net lane passed immediately when isolated. No MossTank, plugin API, plugin +UI, Runtime-owner, or #451 guard test failed. + +Live hostile discovery checkpoint 2026-08-27: the first surrounded-monster +gate exposed two coupled compatibility defects. Retail's classic `* Lure` +vulnerability names were absent from the debuff classifier, so an attack-only +profile could misclassify Piercing Lure's "piercing damage" description as a +direct attack. The classifier now recognizes all seven classic elemental Lure +families (while excluding the distinct Lure Blade item spell), and the attack +catalog defensively rejects every host-authored debuff. Target evaluation also +now ports official `dz::a`'s previous-target tie-break after priority and manual +TargetLock: a valid chosen monster remains selected while the character turns, +instead of angle rescans alternating between surrounding monsters. The new +Lure/attack and target-stability regressions bring the focused MossTank lane to +337/337. Connected re-test remains part of the hostile combat gate. + +## Requirement-level completion audit (2026-08-27) + +Completion is deliberately **not** claimed while live evidence remains missing. +The authoritative requirement/evidence map is: + +| Objective requirement | Current evidence | Audit result | +| --- | --- | --- | +| Functionally complete VTank behavior | MT2–MT9 implementation ledger; 337 MossTank behavior/format/expression tests; connected MT1 autocombat acceptance | Proven for implemented policy and formats; the combined hostile physical/magic matrix remains live-unproven | +| Visually verbatim nine-tab VTank surface | `mosstank.xml` contains the exact Options, Profiles, Vitals, Monsters, Items, Consumables, Buffs, Route, Meta order; all nine tabs mounted in `app-release22` | Proven for shell/tab presence and first comparison; projectile debug-marker appearance remains live-unproven | +| Every visible control has real behavior | 190 interactive controls expose 202 bindings (191 unique); `MossTankMarkupContractTests` resolves every binding, verifies delegate shape, and rejects handlerless controls; 137/137 advanced options have explicit writable mappings | Proven statically and by focused controller tests. `WhoYouGonnaCall` intentionally stores its value but performs no action because the official VTank source labels it `No Function` | +| Generic plugin sidepanel; minimizing must not stop plugins | retained `PluginSidePanel`/window-manager tests plus connected hide/restore gate where the hidden buff pass advanced from 91/97 to 77/97 | Proven | +| Modern acdream plugin APIs over canonical owners | additive BCL-only combat, magic, equipment, item, loot, fellowship, enchantment, navigation, object, world-time, login, network, recovery, projectile, selection, storage, command and classifier contracts; 35 focused App/API/UI tests and 16,125 selected Release tests | Proven for the graphical live host; older/no-window implementations explicitly report unavailable and never fabricate success | +| UtilityBelt-compatible expression superset | immutable evaluator tests; all 260 audited public names registered; host-action, object, fellowship, time, login/network, UI, persistence, collection and meta tests | Proven by catalog and semantic family tests; bidirectional two-client network expressions passed live | +| Lifecycle, reconnect, multi-client stability | same-character reconnect and hidden execution passed; peer expressions and two-member fellowship passed; #451 exact trigger survived 100 focus switches and a 30-minute dual-client soak | Proven through soak; #451 cannot close until both current sessions exit gracefully | + +Open completion gates: (1) hostile physical and offensive-magic behavior against +a live target at valid configured range; (2) visible green/red projectile +collision markers with `ShowCollisionDebug`; (3) graceful exit of both current +soak clients with no native or managed failure. These are evidence gaps, not +redefined-away acceptance criteria. diff --git a/docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md b/docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md new file mode 100644 index 00000000..2556a7f6 --- /dev/null +++ b/docs/research/2026-08-26-mosstank-vtank-utilitybelt-research.md @@ -0,0 +1,447 @@ +# MossTank research: Virindi Tank parity and UtilityBelt expressions + +Date: 2026-08-26 + +This report is the requirements baseline for turning MossTank from the small +self-buffing sample into acdream's full automation plugin. The product target is +deliberately broad: **all Virindi Tank functionality**, with UtilityBelt's more +capable expression dialect as the scripting baseline. File compatibility is a +separate decision; behavioral capability is not. + +## 1. Evidence and limits + +The following primary VTank pages were read and cross-checked (the live wiki +and its indexed historical revisions were both used where a mirror was +temporarily unavailable): + +- `http://virindi.net/wiki/index.php/Virindi_Tank` +- `http://virindi.net/wiki/index.php/Virindi_Tank_Standard_Options` +- `http://virindi.net/wiki/index.php/Virindi_Tank_Advanced_Options` +- `http://virindi.net/wiki/index.php/Virindi_Tank_Commands` +- `http://virindi.net/wiki/index.php/Virindi_Tank_Meta_System` +- `http://virindi.net/wiki/index.php/Meta_Expressions` +- `https://utilitybelt.gitlab.io/docs/expressions/` + +The 2026-08-27 MT2 follow-up also verified the exact documented distinctions +that drive the combat scheduler: + +- A+R requires `MinimumRingTargets` inside Ring Range; R without A rings with + any configured target inside range and falls back to standard war outside; +- `UseArcs` prefers an arc over a bolt only at/above `ArcRange`; +- `Void Basic`, `Drain Auto`, and `Harm` are distinct Monsters damage choices; +- `GhostMonsterSpellAttemptCount` counts spell attempts which never start, + while `BlacklistMonsterAttemptCount` counts successful attacks which miss; +- the health-tracker ghost detector is independent and applies to melee, + missile, and magic. + +Sources: the official `Virindi_Tank_Standard_Options`, +`Virindi_Tank_Advanced_Options`, `Virindi_Tank_FAQ`, `Options_List`, and +`Virindi_Tank_Changelog` pages listed above. + +For UtilityBelt, documentation was checked against the primary source rather +than relying on the generated web page alone. The inspected repository was +`https://gitlab.com/utilitybelt/utilitybelt`, commit +`5fe9825a82f38047737768fd92c61dd47d88e467` (2026-03-05). The grammar is +`UtilityBelt/Lib/Expressions/MetaExpressions.g4`; every method carrying an +`ExpressionMethod` attribute was enumerated. The source is MIT licensed. + +This report extends, rather than replaces, +`2026-07-29-vtank-plugin-automation-requirements.md`. That earlier report +already decoded VTank's `.met`, `.nav`, and `.utl` structures from primary +sources and remains the format reference. + +## 2. Complete VTank capability map + +### 2.1 Combat + +VTank is a priority-driven combat controller, not merely an auto-attack loop. +Its supported combat family includes: + +- melee, missile, mage, hybrid, two-handed, Void and Summoning characters; +- Life harm/martyr attacks, grenades, lenses, cast-on-strike weapons, streaks; +- automatic damage/weapon choice and monster-specific weapon, offhand and pet + element overrides; +- monster rules with `DEFAULT` plus ordered first-match expressions; +- per-rule priority from ignore (`-1`) through `4`, attack/debuff flags, + damage type, attack height, ring/streak choices, and void curses; +- target selection by distance, angular deviation, or the hybrid method using + angle inside a configurable cutoff and distance outside it; +- target lock, blacklist/retry behavior, and ghost-target retirement after + failed casts or missing health updates; +- debuff scheduling by one target, priority group, or all targets before + attack; spell-level versus skill-based debuff choice and reapply windows; +- automatic ring use by nearby-target density and arc/bolt choice by range; +- melee high/middle/low attacks, automatic or explicit power, Recklessness; +- pet density, element, refill and test behavior. + +VTank's macro scheduler checks multiple action lists in priority order. Combat +therefore cannot be implemented as an isolated timer: healing, buffing, +navigation, looting, fellowship assistance and combat all need one arbiter. + +### 2.2 Buffing and vitals + +- automatic trained attribute/skill buffs, protections, banes, auras, + regeneration and configured extra buffs; +- protection/bane profile sets, exclusions, level/tier selection, signed + skill-over-difficulty thresholds, force buff and idle top-off; +- time-remaining rebuff and persisted item-buff duration knowledge; +- combat, idle and fellowship-helper vital thresholds; +- kits, vital transfers, post-switch recharge behavior and special healing + items; +- self-dispel in response to high-level vulnerabilities. + +MossTank's current buff engine already owns the first useful subset: known +self buffs, tier/difficulty choice, in-force enchantment timing, force buff, +and stamina/mana upkeep. It remains plugin policy over host primitives. + +### 2.3 Inventory and crafting + +- AutoStack and AutoCram; +- pea splitting and priority rules; +- crafting of kits, foods, arrowheads and special ammunition; +- mana-stone acquisition, filling and application to equipped items; +- lockpick selection and use; +- component, consumable, tool and ammunition upkeep. + +### 2.4 Looting + +- corpse approach/open/retry/timeout/blacklist; +- all/fellow/rare loot modes and priority boosts; +- appraisal/ID wait, unknown-scroll reading and salvage combining; +- a loot-plugin seam, with VTClassic as the canonical ordered, first-match + rule engine over raw and computed item properties; +- actions including no-loot, keep, keep-up-to, salvage, sell, read and custom + user actions. + +The host must expose object property bags, appraisal completion and +transaction primitives. Rule ordering and loot-profile policy belong in +MossTank. + +### 2.5 Navigation + +- circular, linear, once/runback and follow routes; +- points, portals, recalls, pauses, chat, vendor, repeated NPC talk/use, + server-confirmed checkpoints and charged/shift/strafe jumps; +- closest-entry, reversal, arrival/off-course ranges, door use and + follow-around-corners; +- combat/nav priority interaction. + +Route storage belongs to the plugin. The host owes move-to, follow, turn, +jump, use and authoritative-arrival primitives. + +Direct inspection of the official assembly on 2026-08-27 pinned the route +contract more tightly: + +- `eNavType` is Circular, Linear, Target and Once; Once destructively removes + its first completed row and Linear deliberately visits each endpoint once + while flipping direction; +- `eWaypointType` assigns Point/Portal/Recall/Pause/ChatCommand/OpenVendor/ + Portal2/UseNPC/Checkpoint/Jump to numeric ids 0..9; +- `fd.cs` turns outside 4°, moves while turning only within 45° beyond 3 m or + 15° inside 3 m, and stops at `NavCloseStopRange` (default 2 m); +- `gr.cs` compares the checkpoint against the last server position rather + than client prediction and nudges forward after 15 seconds without an + acknowledgement; +- `gl.cs` records the followed player's path by approximately 9.6 cm and + drops old breadcrumbs when the follower comes within 2.4 m of a later path + segment, preserving follow-around-corners; +- `e9.cs` and `fa.cs` reacquire exact-name/class objects within 2.5 m of the + stored position. Portal2 retries when portal exit remains within 15 m of + its origin; UseNPC repeats until the named NPC tells or gives to the player; +- `b7.cs` is a rule independent of the route node list. `OpenDoors` defaults + false; it IDs doors at 20 m, opens at 4 m, and accepts a lock when Lockpick + is at least `difficulty - 50` using an owned lockpick. + +These are plugin policies over additive canonical projections, not a second +movement model. The host applies semantic movement intent through Runtime's +existing command interpreter and supplies the accepted server position needed +only by Checkpoint. + +### 2.6 Fellowship and social automation + +- tell-driven recruitment and waiting lists; +- fellowship leader/member/state queries and leader replacement voting; +- fellowship healing, corpse permissions and coordinated target/debuff policy; +- multi-client composition through chat rather than a privileged macro API. + +### External loot-classifier seam + +VTank loads one `LootPluginBase`, asks `DoesPotentialItemNeedID`, and then +calls `GetLootDecision(GameItemInfo)`. Its public result vocabulary is +NoLoot, Keep, Salvage, Sell, Read, User1–User5 and KeepUpTo with `Data1` as the +limit. MossTank modernizes discovery into a host-owned classifier registry: +plugins register a namespaced classifier for their own lifetime, while +MossTank remains the corpse/appraisal/pickup/action executor. The selected +engine is durable policy; if it unloads, MossTank returns no classifier match +instead of silently applying the built-in profile. + +Direct inspection of the official `hv.cs` also shows that a custom loot +plugin's per-item action is retained only after the item enters owned +inventory and is removed when the item leaves. The modern registry therefore +has matching `OnLooted` and `OnItemRemoved` callbacks. MossTank invokes them +only from authoritative inventory publication/removal, never when pickup is +merely dispatched. + +### Options-page scheduler findings + +The official Options controls are not merely presentation aliases: + +- `fz.cs` runs the ordinary `RebuffTimeRemainingSeconds` rule before combat; +- `cLogic.cs` runs a second `IdleBuffTopoffTimeSeconds` pass only behind + `IdleBuffTopoff`, after attack/loot work has gone idle; +- the PRETARGETAPPROACH `g8` rule navigates only between `AttackDistance` and + `ApproachDistance`, and requires both combat and navigation to be enabled; +- `cm.cs` changes to Peace only as the final no-target/no-work fallback. + +The UI displays AC-distance settings multiplied by 240. MossTank stores metres +in its typed controllers and converts only at the VTank option boundary. + +### 2.7 Meta state machine + +- named states beginning at `Default`; +- state-local rules, each firing once per state entry; +- nested conditions (`All`, `Any`, `Not`) and conditions for chat regex, + inventory, timers, nav state, death, vendors, monsters, buffs, coordinates, + portals, burden, route distance, expressions and captured chat groups; +- actions for state transition, chat, grouped actions, embedded navigation, + call/return stack, expression execution, expression-derived chat, watchdogs, + option read/write and runtime-created views; +- a roughly 293 ms decision cadence plus evaluation when the macro asks for + its next action. + +### 2.8 Profiles, commands and companion behavior + +- independent settings, navigation, loot and meta profiles; global and + per-character variants; hot loading and automatic persistence; +- command parity for macro state, options, buffing, meta, item testing, + property dumps, monster/spell diagnostics, route editing, attack power and + debug output; +- extensibility equivalent to VTClassic, VI2, item tools, follower/status HUD, + alerts and cross-character inventory. Some belong as separate acdream + plugins, but MossTank's API must permit them without privileged host code. + +## 3. Expression language target + +### 3.1 Why UtilityBelt is the baseline + +VTank expressions are enough to power classic metas, but UtilityBelt preserves +the familiar syntax while adding typed lists and dictionaries, slicing, +higher-order collection functions, broader object queries and more action +primitives. MossTank should implement the UtilityBelt-compatible semantic +superset and offer a VTank compatibility mode for old expressions. + +### 3.2 Grammar and evaluation semantics + +The audited UtilityBelt grammar supports: + +- multiple `;`-separated statements, returning the final result; +- session (`$`), persistent (`@`) and global (`&`) variables; +- decimal and hexadecimal numbers, booleans and two string forms; +- function calls using `name[...]`; +- typed values: number, string, boolean, list, dictionary, coordinate, world + object, stopwatch and UI control; +- list/string/dictionary indexing, slices and negative indices; +- complement, shifts, bitwise operators, exponentiation, arithmetic, regex + match (`#`), comparison, short-circuit `&&` and `||`; +- registered function metadata, arity/type validation and documented return + types; +- collection creation/mutation/copying plus map/filter/reduce/sort/range. + +Implementation requirements follow directly: parse into an immutable AST; +compile or interpret without ambient reflection; use explicit value kinds; +short-circuit logical nodes; attach cancellation and an instruction budget; +make all world/action functions capabilities supplied by the MossTank engine; +and serialize only persistent/global variable stores. + +### 3.3 Audited UtilityBelt function catalog (260 declarations) + +The declaration count includes aliases/overloads. Grouped by capability, the +public names are: + +- **language/conversion/math:** `abs`, `acos`, `asin`, `atan`, `atan2`, + `ceiling`, `chr`, `cnumber`, `cos`, `cosh`, `cstr`, `cstrf`, `floor`, + `hexstr`, `iif`, `ifthen`, `isfalse`, `istrue`, `lumavg`, `lumtotal`, + `ord`, `randint`, `round`, `sin`, `sinh`, `sqrt`, `strlen`, `tan`, `tanh`, + `tostring`, `vitae`; +- **variables:** `getvar`, `setvar`, `testvar`, `touchvar`, `clearvar`, + `clearallvars` and the corresponding `pvar` and `gvar` families; +- **execution/chat:** `exec`, `delayexec`, `clearexec`, `echo`, `chatbox`, + `chatboxpaste`; +- **lists:** `listcreate`, `listadd`, `listinsert`, `listremove`, + `listremoveat`, `listgetitem`, `listcontains`, `listindexof`, + `listlastindexof`, `listcopy`, `listreverse`, `listpop`, `listcount`, + `listclear`, `listfilter`, `listmap`, `listreduce`, `listsort`, + `listfromrange`; +- **dictionaries:** `dictcreate`, `dictgetitem`, `dictadditem`, `dicthaskey`, + `dictremovekey`, `dictkeys`, `dictvalues`, `dictsize`, `dictclear`, + `dictcopy`; +- **time/location:** `getdatetimelocal`, `getdatetimeutc`, `getunixtime`, + `getworldname`, `getplayercoordinates`, `getplayerlandblock`, + `getplayerlandcell`, coordinate parse/get/distance/string functions, + stopwatch functions, and the eleven `getgame*`/day/night functions; +- **character:** raw typed property reads, base/buffed skills, training level, + base/current/buffed-max vitals, base/buffed attributes, burden, free slots, + cooldown expiration, account hash and character index; +- **spells/components:** `getknownspells`, `getisspellknown`, + `getcancastspell_buff`, `getcancastspell_hunt`, `getspellexpiration`, + `getspellexpirationbyname`, `spelldata`, `spellname`, `componentdata`, + `componentname`; +- **world objects:** validity/data/ID-time, raw typed properties, identity, + health/vitals, spells, coordinates, selection/player/open-container, door + state, nearest monster/door/by class/name/template, and `wobjectfindall*` + variants over world, landscape, inventory and containers; +- **actions:** select, use, apply, give, equip wand, cast, cast-on-target, + move, split and drop; +- **combat/movement:** combat state get/set, busy state, equipped weapon type, + heading/get-heading-to, motion get/set/clear and portal-state query; +- **inventory/loot/salvage:** counts by name/regex/type, give-profile, + unopened corpse queries, `ustadd`, `ustopen`, `ustsalvage`; +- **fellowship/quest/XP:** thirteen fellowship queries, quest state/progress, + seven XP-meter operations; +- **UI/options/network/login:** status HUD, view/control get/set/visibility, + VT option/meta get/set, macro status, UtilityBelt options, regex capture, + network clients and next-login control. + +This catalog is a compatibility test ledger. Each name must eventually be +implemented, deliberately aliased, or marked unsupported with a documented +reason; silent omission is not acceptable. + +## 4. acdream mapping after the 2026-08 campaigns + +The 2026-07 report's architecture remains correct, but its gap table is stale. +The Runtime now owns inventory transactions, selection, combat mode and power +state, casting, fellowship, allegiance, vendor and secure-trade state. MossTank +already consumes a small BCL-only `IAutomationSurface` for vitals, skills, +spells, enchantments, casting and local chat. + +The gaps relevant to the first autocombat milestone are narrower: + +| Need | Canonical owner today | Plugin gap | +|---|---|---| +| hostile query and live position | `RuntimeEntityDirectory` + `ClientObjectTable` | no target snapshot/query | +| health and selected target | `RuntimeActionState` | no combat view | +| melee/missile charge/release | `RuntimeCombatAttackState` | no command surface | +| combat-mode transition | `RuntimeCombatModeState` | no command surface | +| known offensive spells | `Spellbook` | only self buffs are enumerated | +| target-specific cast | selection + `RuntimeSpellCastState` | possible only by composing two old services | +| polished plugin controls | retained `IUiRegistry` markup | markup lacks bound visibility/enabled/style affordances | + +The first implementation therefore does not need a second runtime bridge or a +second object model. It needs a narrow additive projection of those exact +owners. + +## 5. Decisions for MossTank + +1. MossTank remains an ordinary plugin. It never references App, Runtime, + rendering, networking or DAT assemblies. +2. The host API exposes snapshots and attempt-style commands; MossTank owns + target scoring, rule ordering, spell/attack choice and timing. +3. The first combat milestone supports melee, missile and direct offensive + magic, target lock, range/angle/hybrid scoring, priority rules, attack + height and power. Navigation, weapon swapping, debuffs, vulnerabilities, + pets and monster expressions are later combat slices, not hidden stubs. +4. Expressions will use UtilityBelt's richer typed semantics. Compatibility + is defined by parser/evaluator tests and the audited function ledger, not by + copying UtilityBelt implementation code. +5. Native MossTank profiles will be versioned JSON. Importers for VTank files + can be added later without constraining the internal model. +6. The UI uses acdream's retained plugin UI contract. Missing generic controls + should improve that contract/markup rather than making MossTank depend on a + presentation implementation. + +### 5.1 Follow-up implementation findings (2026-08-27) + +VTank's official `e0.d(name)` first looks in `MonsterDamageOverrides`, then +maps the monster to `SpeciesDamages`; `ga.g(...)` walks that ordered preference +list and finally tries the unlisted elements 0..6. acdream already projects +retail `CreatureType` as `PluginCombatTarget.SpeciesId`, so MossTank can bypass +VTank's name-to-species compatibility table while preserving the same ordered +damage result. Exact name overrides still win. The imported official feed has +59 overrides and 103 species rows. + +### 5.2 Official inventory and loot findings (2026-08-27) + +The official VTank assembly and its GameInfoDB were inspected rather than +inferring behavior from the UI labels: + +- `el.cs`/`cf.cs` supply 757 exact craft rows; prerequisites are recursive and + share the canonical item-use transaction; +- `fo.cs` identifies every corpse before selection, parses `Killed by ...`, + admits the player's own corpse immediately, admits a Share Loot fellow + immediately, waits 100 seconds for a non-sharing fellow or unrelated public + corpse, and never crosses ownership on another player's rare-generating + corpse; +- the default corpse-open retry contract is 30 attempts, then a 200-second + blacklist; completed corpse records expire after 60 minutes; +- `hv.cs` applies the ordered loot rule first, then falls back to readable + unknown scrolls and automatic mana-stone/tank acquisition; +- `dy.cs` proves that ManaTank is a mana-bearing donor target, not a worn-item + recharge consumable. A ManaStone is used on that donor when its mana is at + least `ManaTankMinimumMana` (default 1000); +- `c7.cs` combines only same-material salvage bags in exact workmanship bands + `<7`, `7–<9`, `9–<10`, and exactly `10`; one bugged source is abandoned after + 40 failed combine attempts; +- `gmSalvageUI::Salvage` calls + `CM_Inventory::Event_CreateTinkeringTool`: game action `0x027D`, tool id, + then `PackableList` (count plus ordered item ids). This same + operation handles ordinary source salvage and salvage-bag combination. + +The native implementation keeps settings, loot, route, and meta documents +independent, matching VTank's profile model while using versioned JSON as the +working format. It also emits and imports exact compatibility files: `uTank2 +NAV 1.2`, CondAct `.met`, and VTClassic `UTL 1` (plus legacy UTL v0 reads). +The UTL port preserves unknown length-delimited requirement and extra-block +payloads, executes the complete 31-type requirement vocabulary, and carries +the `SalvageCombine` material ranges/value modes into the live combine planner. +VTClassic's color rules use the original ordered ObjDesc subpalettes and the +original sample index `length*16 + offset*32 + 8`, resolved from portal DAT +palette colors rather than approximated from icon pixels. + +Profiles are implemented over manifest-scoped JSON with exact VTank files as +an interchange/export layer: `By char` hashes the canonical character name into a distinct +document, named profiles are explicit shared snapshots, and the index records +owner plus per-character active selection. Create/copy/clear/select all hot- +load the same mutable policy owners already borrowed by the controllers. The +generic retained markup contract gained editable fields and retail dropdown +menus for this editor; later Monsters, Loot, Route, and Meta editors reuse the +same controls. + +## 6. Acceptance boundary for “autocombat ported” + +The milestone is complete when an in-world MossTank panel can enable/disable +combat, periodically capture canonical hostile targets, preserve a valid +locked target, choose a target by configured range/angle/hybrid policy and +priority, enter the equipped default combat mode, drive retail's physical +press/charge/release state machine at configured height/power, or cast the +best usable learned direct offensive spell in magic mode. It must stop cleanly +on session loss, invalid/dead/out-of-range targets, and user disable; it must +not duplicate Runtime state or issue overlapping requests. + +Full VTank parity is the campaign target. This acceptance boundary is only the +first executable slice requested for this work session. + +## 7. Official binary combat-item findings (2026-08-27) + +The official `vt.tar.gz` update was decompiled for behavior research and the +live GameInfoDB v9 feed was read directly. The decisive implementations are +`dz.cs` (debuff source selection), `ga.cs` (item classification), `gs.cs` +(caster-item confirmation), `bo.cs` (physical/proc confirmation), and `hi.cs` +(attack-power policy). + +- `dz.b.CompareTo` ranks spell quality then source skill/spellcraft for + `SpellLevel`, reverses those two for `Skill`, and gives a learned spell the + final tie. Spell quality is normally spell difficulty. +- Caster items activate on the target. Melee/missile proc weapons are equipped + and repeatedly attack at power 0/1 respectively. Neither path counts as + applied until color-7 combat chat matches `^You cast (.*) on .*$`. +- Grenades are missile-class items with CombatUse 0 and `Phial` in the name; + the official database contains exactly 72 names across eight material tiers, + with Alchemy requirements 75..400 and spellcraft 100..520. +- Normal physical attack power is not a smooth heuristic. `hi.cs` emits the + exact 0, .2, .49, .5 or 1 values for slash/pierce hybrid arrangements, then + clamps to .11..90 when trained Recklessness is enabled. + +These findings require three host facts VTank formerly obtained through +Decal: retained per-item appraisal SpellBooks, ordered transcript capture, and +an explicit combat-mode command. They are additive BCL plugin contracts; +MossTank retains all source-choice and retry policy. diff --git a/docs/superpowers/specs/2026-08-26-mosstank-autocombat-design.md b/docs/superpowers/specs/2026-08-26-mosstank-autocombat-design.md new file mode 100644 index 00000000..87c0f26e --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-mosstank-autocombat-design.md @@ -0,0 +1,95 @@ +# MossTank autocombat design + +Date: 2026-08-26 + +## Outcome + +Ship the first VTank-class MossTank milestone: a polished in-client controller +that performs safe automatic melee, missile or direct-spell combat while all +policy remains in the plugin and all authoritative state/actions remain in +Runtime. + +## Architecture + +```text +Runtime canonical owners + entity directory + object table + selection + combat + spellbook + | + v +AppAutomationSurface (borrowed projection, no ownership) + PluginCombatTarget[] + PluginCombatSnapshot + attempt commands + | + v +MossTank CombatController (policy/state machine) + scan -> score/lock -> mode -> charge/cast -> wait -> repeat + | + v +retained plugin panel (bindings only) +``` + +`AcDream.Plugin.Abstractions` stays BCL-only. New interfaces use records, +enums, arrays/lists and primitives only. Existing interfaces gain default +members where needed so API v1 plugins remain loadable. + +## API additions + +- `PluginCombatTarget`: id, name, weenie class, distance, signed relative + angle, health-known and health fraction. +- `PluginCombatSnapshot`: selected id, mode, charge/request state, power and + server-pending state. +- `ICombatAutomation`: immutable hostile snapshot plus explicit mode, + begin/release/abort attempts. +- `ISpellCatalog.KnownAttackSpells`: learned, direct offensive spells. +- `IAutomationSurface.Combat`: the combat group. + +Attempt results distinguish unavailable, invalid target, wrong mode, busy, +transition started and sent/started. This avoids `bool` APIs whose `false` +cannot tell a plugin whether to wait, retry, reselect or stop. + +## Target snapshots + +`RuntimeHostileTargetQuery` is extended with a snapshot capture method. It +borrows the same entity directory and `ClientObjectTable` used by gameplay, +filters with the same `CombatTargetPolicy`, and computes distance and relative +heading using retail's `MoveToMath` helpers. Hidden, no-draw, dead and +cell-less entities are excluded. The App surface refreshes at bounded cadence +and publishes one immutable list reference; retained UI reads do not scan the +world or allocate. + +## Combat controller + +States: + +1. `Off`: no automation command may be emitted. +2. `Acquire`: keep a valid lock or choose the lowest score. +3. `Mode`: request the equipped default combat mode and wait for confirmation. +4. `PhysicalCharge`: select, set power, press height, then wait until the + canonical meter reaches desired power before release. +5. `MagicCast`: select and cast the chosen known offensive spell. +6. `Wait`: wait while physical server response, repeat state or magic busy is + active, then reacquire/repeat. + +Target scoring first applies ordered rules (initial slice supplies a default +priority and an ignore-name list), then applies the configured selection +method. Target lock keeps the current target while it remains admissible. + +The controller never fabricates success. Health and disappearance retire a +target; timeouts return to `Acquire`; session loss transitions to `Off` and +aborts an in-progress physical build. + +## UI + +The main window becomes a dashboard rather than a single force-buff button: +macro toggle, current target/mode, state, vitals, combat settings, buff action +and settings navigation. Generic markup gains bound child visibility/enabled +and color/style attributes so active controls read as active without App types +leaking into the plugin. + +## Verification + +- pure controller tests for selection policies, lock, mode transition, + charge/release, busy suppression, magic choice, disable and session loss; +- Runtime query tests for filter, range, distance, relative angle and health; +- App projection tests for caching and command mapping where practical; +- markup parser tests for new generic bindings; +- MossTank, Runtime, App and complete Release solution gates. diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index ec9fe281..3ccc68c8 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -112,13 +112,17 @@ AfterTargets="Build" Condition="'$(IsCrossTargetingBuild)' != 'true'"> - <_SmokePluginSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/bin/$(Configuration)/$(TargetFramework) - <_SmokePluginSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_SmokePluginSourceDir)/$(RuntimeIdentifier) <_SmokePluginDestDir>$(OutputPath)plugins/AcDream.Plugins.Smoke + + + - <_SmokePluginPublishSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.Smoke/bin/$(Configuration)/$(TargetFramework) - <_SmokePluginPublishSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_SmokePluginPublishSourceDir)/$(RuntimeIdentifier) <_SmokePluginPublishDestDir>$(PublishDir)plugins/AcDream.Plugins.Smoke + + + - <_MossTankSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/bin/$(Configuration)/$(TargetFramework) - <_MossTankSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_MossTankSourceDir)/$(RuntimeIdentifier) <_MossTankDestDir>$(OutputPath)plugins/AcDream.Plugins.MossTank + + + - <_MossTankPublishSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/bin/$(Configuration)/$(TargetFramework) - <_MossTankPublishSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_MossTankPublishSourceDir)/$(RuntimeIdentifier) <_MossTankPublishDestDir>$(PublishDir)plugins/AcDream.Plugins.MossTank + + + ? RenderPackDiagnostics = null, - string? ScreenshotsDirectory = null) + string? ScreenshotsDirectory = null, + AppAutomationSurface? Automation = null) { public RuntimeActionState Actions => Runtime.ActionOwner; @@ -429,6 +430,13 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory return false; activeSession.SendSell(vendorGuid, items); return true; + }, + sendSalvage: (toolGuid, itemGuids) => + { + if (session.CurrentSession is not { } activeSession || !session.IsInWorld) + return false; + activeSession.SendSalvage(toolGuid, itemGuids); + return true; }); } @@ -1230,7 +1238,10 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory $"Screenshot failed: {error}", RetailLogTextType.ClientLocal); } - }); + }, + ProjectileDebugSamples: d.Automation is null + ? null + : d.Automation.CaptureProjectileDebugSamples); RetailUiRuntime runtime = lease.Mount( () => RetailUiRuntime.CreateUninitialized(bindings)); checkpoint(InteractionRetainedUiCompositionPoint.UiRuntimeMounted); diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 2317eb30..da60f580 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -83,6 +83,7 @@ internal sealed record SessionPlayerDependencies( CombatFeedbackSlot CombatFeedback, TransferableResourceSlot PortalTunnelFallback, Action Log, + Func? TryHandlePluginCommand, /// Campaign LA slice LA1: the shared per-session status-event /// writer, no-op when was /// not configured. @@ -112,6 +113,7 @@ internal sealed record SessionPlayerResult( DatSpawnClaimHydrationClassifier SpawnClaimHydration, LiveSessionController LiveSession, LiveEntityHydrationController Hydration, + LiveEntityDeletionController Deletion, LiveEntityNetworkUpdateController NetworkUpdates, LiveEntityLivenessController Liveness, LiveEntitySessionController SessionEvents, @@ -357,7 +359,8 @@ internal sealed class SessionPlayerCompositionPhase // LiveSessionCommandSurface has no dependencies of its own, so // hoisting its construction is inert; the later site now reuses // this instance instead of constructing a second one. - var liveSessionCommands = new LiveSessionCommandSurface(); + var liveSessionCommands = new LiveSessionCommandSurface( + d.TryHandlePluginCommand); var settingsTargets = new RuntimeSettingsTargets( new SilkRuntimeDisplayWindowTarget(d.Window), live.DrawDispatcher, @@ -1332,6 +1335,7 @@ internal sealed class SessionPlayerCompositionPhase spawnClaimClassifier, liveSession, hydration, + deletion, networkUpdates, liveness, sessionEvents, diff --git a/src/AcDream.App/Input/DispatcherMovementInputSource.cs b/src/AcDream.App/Input/DispatcherMovementInputSource.cs index 876ac7ad..10bf5a8f 100644 --- a/src/AcDream.App/Input/DispatcherMovementInputSource.cs +++ b/src/AcDream.App/Input/DispatcherMovementInputSource.cs @@ -63,7 +63,7 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource return default; if (_movement.HasCommandInput) - return _movement.CommandInput; + return _movement.CommandInput with { IsPersistentCommand = true }; if (_dispatcher is not { } dispatcher) return default; diff --git a/src/AcDream.App/Net/LiveSessionAppSource.cs b/src/AcDream.App/Net/LiveSessionAppSource.cs index f10e4e9e..d8e27646 100644 --- a/src/AcDream.App/Net/LiveSessionAppSource.cs +++ b/src/AcDream.App/Net/LiveSessionAppSource.cs @@ -36,11 +36,17 @@ internal sealed class LiveSessionAppSource /// retained UI may keep this surface, while the displaced route itself becomes /// inert before inbound subscriptions detach. /// -internal sealed class LiveSessionCommandSurface : ICommandBus +internal sealed class LiveSessionCommandSurface : IPluginCommandBus { private readonly object _gate = new(); + private readonly Func? _tryHandlePluginCommand; private LiveSessionCommandRouter? _active; + public LiveSessionCommandSurface(Func? tryHandlePluginCommand = null) + { + _tryHandlePluginCommand = tryHandlePluginCommand; + } + public ILiveSessionCommandRouting Attach(LiveSessionCommandRouter route) { ArgumentNullException.ThrowIfNull(route); @@ -65,6 +71,9 @@ internal sealed class LiveSessionCommandSurface : ICommandBus route?.Publish(command); } + public bool TryHandlePluginCommand(string commandLine) => + _tryHandlePluginCommand?.Invoke(commandLine) == true; + private void Release(LiveSessionCommandRouter expected) { expected.Dispose(); diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index 6ea3a7b6..ec26a68b 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -455,6 +455,7 @@ internal sealed class LiveSessionRuntimeFactory OnUseDone: error => { _domain.Inventory.ExternalContainers.ApplyUseDone(error); + _domain.Actions.SpellCast.CompleteUse(error); _domain.Actions.Transactions.CompleteUse(error); }, _domain.Inventory.ItemMana, diff --git a/src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs b/src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs index 15810643..0b195c56 100644 --- a/src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs +++ b/src/AcDream.App/Platform/GraphicalWindowBackendSelection.cs @@ -151,6 +151,13 @@ internal static class GraphicalWindowBackendConfigurator _ => throw new ArgumentOutOfRangeException( nameof(requested)), }); + // #451: InitHint proves the packaged glfw3.dll is loaded but runs + // before glfwInit creates any window or begins polling. This is + // the one safe point to narrow GLFW's GetActiveWindow import so a + // temporarily joined Win32 input queue cannot hand it another + // acdream process's private GLFWwindow pointer. + if (platform.OperatingSystem == GraphicalHostOperatingSystem.Windows) + Win32GlfwActiveWindowGuard.Install(); _glfw = glfw; _configuredProtocol = requested; } diff --git a/src/AcDream.App/Platform/Win32GlfwActiveWindowGuard.cs b/src/AcDream.App/Platform/Win32GlfwActiveWindowGuard.cs new file mode 100644 index 00000000..629ab07d --- /dev/null +++ b/src/AcDream.App/Platform/Win32GlfwActiveWindowGuard.cs @@ -0,0 +1,282 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace AcDream.App.Platform; + +/// +/// Prevents GLFW's Win32 modifier-key repair pass from accepting a window +/// owned by another process. +/// +/// +/// +/// GLFW 3.4's _glfwPollEventsWin32 calls GetActiveWindow, then +/// reads that HWND's process-global GLFW property and dereferences the +/// result as a local _GLFWwindow*. Normally GetActiveWindow can +/// only return a window from this thread's input queue. Windows automation, +/// accessibility software, and some multi-box window managers temporarily +/// join input queues, however, allowing it to return another acdream process's +/// window. Every GLFW process uses the same property name, so GetPropW +/// then succeeds but returns a pointer meaningful only in the other process. +/// The next modifier-key read is an access violation (#451). +/// +/// +/// Patch only GLFW's import-address-table entry for GetActiveWindow. +/// The replacement returns the real active HWND when it belongs to this +/// process and zero otherwise. Zero is GLFW's existing, intentional +/// "nothing to repair" path. No process-global Win32 hook is installed and +/// no other module's User32 calls are changed. +/// +/// +internal static unsafe class Win32GlfwActiveWindowGuard +{ + private const string GlfwModuleName = "glfw3.dll"; + private const string User32ModuleName = "USER32.dll"; + private const string GetActiveWindowImport = "GetActiveWindow"; + private const uint PageReadWrite = 0x04; + private const ushort DosSignature = 0x5A4D; + private const uint PeSignature = 0x00004550; + private const ushort Pe32Magic = 0x010B; + private const ushort Pe32PlusMagic = 0x020B; + private const int ImportDescriptorSize = 20; + + private static readonly uint CurrentProcessId = + checked((uint)Environment.ProcessId); + private static int _installState; + + internal static bool IsInstalled => Volatile.Read(ref _installState) == 1; + + internal static void Install() + { + if (!OperatingSystem.IsWindows() + || Interlocked.CompareExchange(ref _installState, 2, 0) != 0) + { + return; + } + + try + { + nint module = GetModuleHandleW(GlfwModuleName); + if (module == 0 + || !TryFindImportSlot( + module, + User32ModuleName, + GetActiveWindowImport, + out nint slot)) + { + Volatile.Write(ref _installState, -1); + Console.Error.WriteLine( + "windowing: could not install the GLFW foreign-active-window guard"); + return; + } + + nint replacement = (nint)(delegate* unmanaged[Stdcall]) + &GetCurrentProcessActiveWindow; + if (!VirtualProtect( + slot, + checked((nuint)IntPtr.Size), + PageReadWrite, + out uint oldProtection)) + { + Volatile.Write(ref _installState, -1); + Console.Error.WriteLine( + "windowing: GLFW active-window import was not writable"); + return; + } + + try + { + *(nint*)slot = replacement; + } + finally + { + _ = VirtualProtect( + slot, + checked((nuint)IntPtr.Size), + oldProtection, + out _); + } + + Volatile.Write(ref _installState, 1); + Console.WriteLine( + "windowing: GLFW foreign-active-window guard installed (#451)."); + } + catch (Exception failure) + { + Volatile.Write(ref _installState, -1); + Console.Error.WriteLine( + $"windowing: GLFW active-window guard failed: {failure.Message}"); + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static nint GetCurrentProcessActiveWindow() + { + nint window = GetActiveWindow(); + if (window == 0) + return 0; + + _ = GetWindowThreadProcessId(window, out uint ownerProcessId); + return AcceptWindow(window, ownerProcessId, CurrentProcessId); + } + + internal static nint AcceptWindow( + nint window, + uint ownerProcessId, + uint currentProcessId) => + window != 0 + && ownerProcessId != 0 + && ownerProcessId == currentProcessId + ? window + : 0; + + private static bool TryFindImportSlot( + nint module, + string importedModule, + string importedFunction, + out nint slot) + { + slot = 0; + byte* image = (byte*)module; + if (*(ushort*)image != DosSignature) + return false; + + int peOffset = *(int*)(image + 0x3C); + if (peOffset <= 0 || *(uint*)(image + peOffset) != PeSignature) + return false; + + byte* optionalHeader = image + peOffset + 24; + ushort magic = *(ushort*)optionalHeader; + int dataDirectoryOffset; + int thunkSize; + ulong ordinalFlag; + if (magic == Pe32PlusMagic) + { + dataDirectoryOffset = 112; + thunkSize = 8; + ordinalFlag = 0x8000000000000000UL; + } + else if (magic == Pe32Magic) + { + dataDirectoryOffset = 96; + thunkSize = 4; + ordinalFlag = 0x80000000UL; + } + else + { + return false; + } + + uint sizeOfImage = *(uint*)(optionalHeader + 56); + uint importRva = *(uint*)(optionalHeader + dataDirectoryOffset + 8); + uint importSize = *(uint*)(optionalHeader + dataDirectoryOffset + 12); + if (!Contains(sizeOfImage, importRva, ImportDescriptorSize)) + return false; + + int descriptorLimit = importSize >= ImportDescriptorSize + ? checked((int)(importSize / ImportDescriptorSize)) + : checked((int)((sizeOfImage - importRva) / ImportDescriptorSize)); + for (int descriptorIndex = 0; + descriptorIndex < descriptorLimit; + descriptorIndex++) + { + byte* descriptor = image + + importRva + + descriptorIndex * ImportDescriptorSize; + uint originalFirstThunk = *(uint*)descriptor; + uint nameRva = *(uint*)(descriptor + 12); + uint firstThunk = *(uint*)(descriptor + 16); + if (originalFirstThunk == 0 && nameRva == 0 && firstThunk == 0) + break; + if (!MatchesAsciiZ(image, sizeOfImage, nameRva, importedModule, true)) + continue; + if (originalFirstThunk == 0 + || !Contains(sizeOfImage, originalFirstThunk, thunkSize) + || !Contains(sizeOfImage, firstThunk, thunkSize)) + { + return false; + } + + int thunkLimit = checked((int)Math.Min( + (sizeOfImage - originalFirstThunk) / (uint)thunkSize, + (sizeOfImage - firstThunk) / (uint)thunkSize)); + for (int thunkIndex = 0; thunkIndex < thunkLimit; thunkIndex++) + { + ulong nameThunk = thunkSize == 8 + ? *(ulong*)(image + originalFirstThunk + thunkIndex * thunkSize) + : *(uint*)(image + originalFirstThunk + thunkIndex * thunkSize); + if (nameThunk == 0) + break; + if ((nameThunk & ordinalFlag) != 0) + continue; + + uint importByNameRva = checked((uint)nameThunk); + if (!Contains(sizeOfImage, importByNameRva, 3) + || !MatchesAsciiZ( + image, + sizeOfImage, + importByNameRva + 2, + importedFunction, + false)) + { + continue; + } + + slot = (nint)(image + firstThunk + thunkIndex * thunkSize); + return true; + } + + return false; + } + + return false; + } + + private static bool Contains(uint imageSize, uint offset, int length) => + length >= 0 + && offset < imageSize + && (ulong)offset + (uint)length <= imageSize; + + private static bool MatchesAsciiZ( + byte* image, + uint imageSize, + uint offset, + string expected, + bool ignoreCase) + { + if (!Contains(imageSize, offset, expected.Length + 1)) + return false; + + for (int i = 0; i < expected.Length; i++) + { + char actual = (char)image[offset + (uint)i]; + char wanted = expected[i]; + if (ignoreCase) + { + actual = char.ToUpperInvariant(actual); + wanted = char.ToUpperInvariant(wanted); + } + if (actual != wanted) + return false; + } + return image[offset + (uint)expected.Length] == 0; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern nint GetModuleHandleW(string moduleName); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool VirtualProtect( + nint address, + nuint size, + uint newProtection, + out uint oldProtection); + + [DllImport("user32.dll")] + private static extern nint GetActiveWindow(); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId( + nint window, + out uint processId); +} diff --git a/src/AcDream.App/Plugins/AppAutomationSurface.cs b/src/AcDream.App/Plugins/AppAutomationSurface.cs index 7ad88d74..8667bbbe 100644 --- a/src/AcDream.App/Plugins/AppAutomationSurface.cs +++ b/src/AcDream.App/Plugins/AppAutomationSurface.cs @@ -1,9 +1,22 @@ using AcDream.Core.Chat; +using AcDream.Core.Combat; +using AcDream.Core.Items; using AcDream.Core.Player; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.Core.Plugins; +using AcDream.Core.Properties; +using AcDream.Core.Selection; using AcDream.Core.Spells; +using AcDream.Core.World; +using AcDream.Core.CharGen; +using AcDream.Content; using AcDream.Plugin.Abstractions; +using AcDream.App.Runtime; using AcDream.Runtime; +using AcDream.Runtime.Entities; using AcDream.Runtime.Gameplay; +using AcDream.Runtime.Session; namespace AcDream.App.Plugins; @@ -27,20 +40,66 @@ namespace AcDream.App.Plugins; /// internal sealed class AppAutomationSurface : IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands, IPluginChat, - IDisposable + ICombatAutomation, IEquipmentAutomation, IItemAutomation, + ILootAutomation, IFellowshipAutomation, IEnchantmentAutomation, + IRuntimeCommunicationObserver, + INavigationAutomation, IWorldObjectAutomation, IWorldTimeAutomation, + ILoginAutomation, INetworkAutomation, IRecoveryAutomation, + IProjectileAutomation, ISelectionAutomation, IDisposable { + private readonly PluginCommandRegistry _pluginCommands; + private const int MaximumPluginChatMessages = 512; + private const double PeerHeartbeatSeconds = 5d; private readonly object _gate = new(); + private readonly IEvents? _events; + private readonly LocalPluginPeerRegistry _peers; + private readonly string[] _peerTags; + private double _peerHeartbeatRemaining; private GameRuntime? _runtime; private RuntimeCommunicationState? _communication; private RuntimeCharacterState? _character; private RuntimeSpellCastState? _cast; private Spellbook? _spellbook; + private MagicCatalog _magicCatalog = MagicCatalog.Empty; private IReadOnlyDictionary _skillNames = new Dictionary(); + private Func _speciesName = static _ => string.Empty; + private IChargenPaletteColorSource? _paletteColors; + private Func? _equip; + private Func? _equipmentBusy; + private Func? _useItem; + private Func? _applyItem; + private Func? _moveItem; + private Func? _mergeItems; + private Func? _dropItem; + private Func? _giveItem; + private Func? _pickupItem; + private Func? _identifyItem; + private Func, bool>? _salvageItems; + private Func? _sellItem; + private Func? _dismissGhost; + private Func? _selectionAction; + private PhysicsEngine? _projectilePhysics; + private IReadOnlyList _projectileDebugSamples = + Array.Empty(); + private long _projectileDebugSamplesExpireAt; + private CurrentGameRuntimeAdapter? _sessionCommands; + private IDisposable? _communicationSubscription; + private readonly List _chatMessages = []; + private ulong _pluginChatSequence; + private long _inventoryCompletionRevision; + private PluginInventoryCompletion _lastInventoryCompletion; + private readonly Dictionary<(uint Target, uint Spell), TrackedEnchantment> + _trackedEnchantments = []; + private long _trackedCastCompletionRevision; private bool _disposed; private IReadOnlyList _knownSelfBuffs = Array.Empty(); + private IReadOnlyList _knownAttackSpells = + Array.Empty(); + private IReadOnlyList _knownCombatSpells = + Array.Empty(); private IReadOnlyList _enchantments = Array.Empty(); @@ -52,6 +111,38 @@ internal sealed class AppAutomationSurface private static readonly string[] AttributeNames = ["Strength", "Endurance", "Quickness", "Coordination", "Focus", "Self"]; + public AppAutomationSurface() + : this(events: null) + { + } + + internal AppAutomationSurface( + IEvents? events, + LocalPluginPeerRegistry? peers = null, + IReadOnlyList? peerTags = null) + { + _pluginCommands = new PluginCommandRegistry((verb, error) => + Console.WriteLine( + $"[PluginCommand:{verb}] {error.GetBaseException().Message}")); + _events = events; + _peers = peers ?? new LocalPluginPeerRegistry(Path.Combine( + AcDream.Platform.ApplicationPathSet.Resolve().DataDirectory, + "plugin-peers")); + _peerTags = (peerTags ?? Array.Empty()) + .Where(static tag => !string.IsNullOrWhiteSpace(tag)) + .Select(static tag => tag.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(128) + .ToArray(); + if (_events is not null) + _events.Tick += OnPeerTick; + } + + internal IPluginCommandRegistry PluginCommands => _pluginCommands; + + internal bool TryHandlePluginCommand(string commandLine) => + _pluginCommands.TryHandle(commandLine); + /// /// True only while a character is actually in world. The runtime's own /// lifecycle state is the signal: at character select the gameplay owners @@ -78,6 +169,172 @@ internal sealed class AppAutomationSurface public ISpellCatalog Spells => this; public IMagicCommands Magic => this; public IPluginChat Chat => this; + public ICombatAutomation Combat => this; + public IEquipmentAutomation Equipment => this; + public IItemAutomation Items => this; + public ILootAutomation Loot => this; + public IFellowshipAutomation Fellowship => this; + public IEnchantmentAutomation Enchantments => this; + public INavigationAutomation Navigation => this; + public IWorldObjectAutomation Objects => this; + public IWorldTimeAutomation WorldTime => this; + public ILoginAutomation Login => this; + public INetworkAutomation Network => this; + public IRecoveryAutomation Recovery => this; + public IProjectileAutomation Projectiles => this; + public ISelectionAutomation Selection => this; + + PluginRecoveryResult IRecoveryAutomation.ClearOneBusyReference() + { + GameRuntime? runtime; + lock (_gate) + { + if (_disposed) + return new(false, Message: "The plugin host is disposed."); + runtime = _runtime; + } + if (runtime is null) + return new(false, Message: "No game session is bound."); + + InventoryTransactionState transactions = + runtime.InventoryOwner.Transactions; + int before = transactions.BusyCount; + // VTank's /vt clearbusy calls ga.e(): decrement one reference and + // clamp at zero. CompleteUse has that exact counter transition without + // resetting an unrelated pending inventory request. + transactions.CompleteUse(0u); + return new( + Accepted: true, + PreviousCount: before, + CurrentCount: transactions.BusyCount, + Message: before == 0 + ? "The action busy count was already zero." + : "Cleared one action busy reference."); + } + + bool INetworkAutomation.IsAvailable + { + get + { + lock (_gate) + return !_disposed; + } + } + + IReadOnlyList INetworkAutomation.CaptureClients() + { + // Production publishes from the host update thread at the bounded + // heartbeat cadence. The no-event path exists only for isolated/test + // hosts and publishes on demand. + if (_events is null) + PublishPeerSnapshot(); + return _peers.CaptureRemoteClients(); + } + + bool ILoginAutomation.IsAvailable + { + get + { + lock (_gate) + return !_disposed && _runtime is not null; + } + } + + uint ILoginAutomation.NextLoginObjectId + { + get + { + lock (_gate) + return _runtime?.Session.NextLoginCharacterId ?? 0u; + } + } + + IReadOnlyList ILoginAutomation.CaptureRoster() + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || _disposed) + return Array.Empty(); + + IRuntimeCharacterSelectionView view = runtime.Session.CharacterSelection; + RuntimeCharacterSelectionSnapshot snapshot = view.Snapshot; + var result = new PluginLoginCharacter[snapshot.RosterCount]; + for (int index = 0; index < result.Length; index++) + { + if (!view.TryGetAt(index, out RuntimeCharacterSelectionEntry entry)) + return Array.Empty(); + result[index] = new PluginLoginCharacter( + entry.CharacterId, + entry.Name, + entry.ActiveIndex, + entry.IsPendingDelete); + } + return result; + } + + bool ILoginAutomation.SetNextLogin(uint characterObjectId) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.Session.TrySetNextLogin(characterObjectId) == true; + } + + bool ILoginAutomation.ClearNextLogin() + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.Session.ClearNextLogin() == true; + } + + PluginWorldTimeSnapshot IWorldTimeAutomation.Snapshot + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return default; + + double rawTicks = runtime.EnvironmentOwner.WorldTime.NowTicks; + DerethCalendar calendar = runtime.EnvironmentOwner.WorldTime.Calendar; + DerethDateTime.Calendar value = calendar.ToCalendar(rawTicks); + int hour = (int)value.Hour; + bool isDay = hour is >= 4 and < 12; + double shiftedTicks = Math.Max(0d, rawTicks) + + calendar.OriginOffsetTicks; + double gameTicks = shiftedTicks + + DerethDateTime.ZeroYear * DerethDateTime.YearTicks; + double withinHour = shiftedTicks + - Math.Floor(shiftedTicks / DerethDateTime.HourTicks) + * DerethDateTime.HourTicks; + double untilNight = isDay + ? ((12 - hour) * DerethDateTime.HourTicks - withinHour) / 60d + : 0d; + int dayHour = hour <= 4 ? hour + 16 : hour; + double untilDay = !isDay + ? ((20 - dayHour) * DerethDateTime.HourTicks - withinHour) / 60d + : 0d; + return new PluginWorldTimeSnapshot( + true, + gameTicks, + value.Year, + (int)value.Month, + value.Day, + hour, + FormatCalendarName(value.Month.ToString()), + FormatCalendarName(value.Hour.ToString()), + isDay, + Math.Max(0d, untilDay), + Math.Max(0d, untilNight)); + } + } + + private static string FormatCalendarName(string value) => value + .Replace("AndHalf", "-and-Half", StringComparison.Ordinal); /// Bind the surface to the runtime's gameplay owners. public void Bind( @@ -95,11 +352,17 @@ internal sealed class AppAutomationSurface DetachLocked(); _runtime = runtime; _communication = runtime.CommunicationOwner; + _communicationSubscription = + runtime.CommunicationOwner.Events.Subscribe(this); _character = character; _cast = cast; _spellbook = spellbook; spellbook.SpellbookChanged += OnSpellbookChanged; spellbook.EnchantmentsChanged += OnEnchantmentsChanged; + runtime.InventoryOwner.Transactions.RequestCompleted += + OnInventoryRequestCompleted; + runtime.InventoryOwner.Transactions.RequestFailed += + OnInventoryRequestFailed; } RebuildSpellbook(); @@ -118,16 +381,143 @@ internal sealed class AppAutomationSurface _skillNames = skillNames; } + /// Supply the immutable retail spell/component DAT catalog. + public void BindMagicCatalog(MagicCatalog catalog) + { + ArgumentNullException.ThrowIfNull(catalog); + lock (_gate) + _magicCatalog = catalog; + } + + /// + /// Bind the current graphical session's typed command adapter. Runtime + /// gameplay owners are process-stable, but the live command bus is + /// session-scoped and is therefore replaced on every new session. + /// + public void BindSessionCommands(CurrentGameRuntimeAdapter commands) + { + ArgumentNullException.ThrowIfNull(commands); + lock (_gate) + _sessionCommands = commands; + } + + /// Supply retail creature-enum display names from portal.dat. + public void BindSpeciesNameResolver(Func resolver) + { + ArgumentNullException.ThrowIfNull(resolver); + lock (_gate) + _speciesName = resolver; + } + + public void BindPaletteColorResolver(IChargenPaletteColorSource resolver) + { + ArgumentNullException.ThrowIfNull(resolver); + lock (_gate) + _paletteColors = resolver; + } + + /// + /// Bind the graphical host's one ItemInteraction/AutoWield owner. Kept + /// separate from Runtime binding because retained interaction composition + /// finishes later during window load. + /// + public void BindEquipment( + Func equip, + Func isBusy) + { + ArgumentNullException.ThrowIfNull(equip); + ArgumentNullException.ThrowIfNull(isBusy); + lock (_gate) + { + _equip = equip; + _equipmentBusy = isBusy; + } + } + + public void BindItems( + Func useItem, + Func applyItem, + Func moveItem, + Func mergeItems, + Func dropItem, + Func giveItem, + Func pickupItem, + Func identifyItem, + Func, bool>? salvageItems = null, + Func? sellItem = null) + { + ArgumentNullException.ThrowIfNull(useItem); + ArgumentNullException.ThrowIfNull(applyItem); + ArgumentNullException.ThrowIfNull(moveItem); + ArgumentNullException.ThrowIfNull(mergeItems); + ArgumentNullException.ThrowIfNull(dropItem); + ArgumentNullException.ThrowIfNull(giveItem); + ArgumentNullException.ThrowIfNull(pickupItem); + ArgumentNullException.ThrowIfNull(identifyItem); + lock (_gate) + { + _useItem = useItem; + _applyItem = applyItem; + _moveItem = moveItem; + _mergeItems = mergeItems; + _dropItem = dropItem; + _giveItem = giveItem; + _pickupItem = pickupItem; + _identifyItem = identifyItem; + _salvageItems = salvageItems; + _sellItem = sellItem; + } + } + + public void BindGhostDeletion(Func dismissGhost) + { + ArgumentNullException.ThrowIfNull(dismissGhost); + lock (_gate) + _dismissGhost = dismissGhost; + } + + /// + /// Bind the process-stable Runtime collision world used by ordinary client + /// physics. The plugin receives only bounded detached query results. + /// + public void BindProjectileCollision(PhysicsEngine physics) + { + ArgumentNullException.ThrowIfNull(physics); + lock (_gate) + _projectilePhysics = physics; + } + + public void BindSelectionActions( + Func execute) + { + ArgumentNullException.ThrowIfNull(execute); + lock (_gate) + _selectionAction = execute; + } + public void Unbind() { lock (_gate) DetachLocked(); + _peers.Withdraw(); _knownSelfBuffs = Array.Empty(); + _knownAttackSpells = Array.Empty(); + _knownCombatSpells = Array.Empty(); _enchantments = Array.Empty(); } private void DetachLocked() { + if (_runtime is { } runtime) + { + runtime.InventoryOwner.Transactions.RequestFailed -= + OnInventoryRequestFailed; + runtime.InventoryOwner.Transactions.RequestCompleted -= + OnInventoryRequestCompleted; + } + _communicationSubscription?.Dispose(); + _communicationSubscription = null; + _chatMessages.Clear(); if (_spellbook is not null) { _spellbook.SpellbookChanged -= OnSpellbookChanged; @@ -138,8 +528,113 @@ internal sealed class AppAutomationSurface _cast = null; _runtime = null; _communication = null; + _dismissGhost = null; + _trackedEnchantments.Clear(); + _trackedCastCompletionRevision = 0; + _projectileDebugSamples = Array.Empty(); + _projectileDebugSamplesExpireAt = 0; } + private void OnPeerTick(double elapsedSeconds) + { + _peerHeartbeatRemaining -= Math.Max(0d, elapsedSeconds); + if (_peerHeartbeatRemaining > 0d) + return; + _peerHeartbeatRemaining = PeerHeartbeatSeconds; + PublishPeerSnapshot(); + } + + private void PublishPeerSnapshot() + { + if (!IsAvailable) + { + _peers.Withdraw(); + return; + } + + ICharacterInfo character = this; + PluginNavigationSnapshot navigation = + ((INavigationAutomation)this).Snapshot; + if (!navigation.IsAvailable || character.ObjectId == 0u) + { + _peers.Withdraw(); + return; + } + + try + { + _peers.Publish(new PluginNetworkClient( + _peers.ClientId, + character.ObjectId, + character.Name, + character.WorldName, + navigation.Position, + _peerTags, + character.CurrentHealth, + character.CurrentMana, + character.CurrentStamina, + character.MaxHealth, + character.MaxMana, + character.MaxStamina, + navigation.Position.HeadingDegrees)); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + + private void OnInventoryRequestCompleted(PendingInventoryRequest request) + { + lock (_gate) + { + if (_disposed) + return; + _lastInventoryCompletion = new PluginInventoryCompletion( + ++_inventoryCompletionRevision, + Project(request.Kind), + request.ItemId, + 0u); + } + } + + private void OnInventoryRequestFailed( + PendingInventoryRequest request, + uint weenieError) + { + lock (_gate) + { + if (_disposed) + return; + _lastInventoryCompletion = new PluginInventoryCompletion( + ++_inventoryCompletionRevision, + Project(request.Kind), + request.ItemId, + weenieError); + } + } + + private static PluginInventoryCommandKind Project(InventoryRequestKind kind) => + kind switch + { + InventoryRequestKind.Pickup => PluginInventoryCommandKind.Pickup, + InventoryRequestKind.PutInContainer => + PluginInventoryCommandKind.PutInContainer, + InventoryRequestKind.SplitToContainer => + PluginInventoryCommandKind.SplitToContainer, + InventoryRequestKind.Merge => PluginInventoryCommandKind.Merge, + InventoryRequestKind.Move => PluginInventoryCommandKind.Move, + InventoryRequestKind.DropToWorld => + PluginInventoryCommandKind.DropToWorld, + InventoryRequestKind.SplitToWorld => + PluginInventoryCommandKind.SplitToWorld, + InventoryRequestKind.Wield => PluginInventoryCommandKind.Wield, + InventoryRequestKind.Give => PluginInventoryCommandKind.Give, + _ => PluginInventoryCommandKind.Unknown, + }; + private void OnSpellbookChanged() => RebuildSpellbook(); private void OnEnchantmentsChanged() => RebuildEnchantments(); @@ -152,28 +647,64 @@ internal sealed class AppAutomationSurface if (spellbook is null) { _knownSelfBuffs = Array.Empty(); + _knownAttackSpells = Array.Empty(); + _knownCombatSpells = Array.Empty(); return; } - var built = new List(); + var buffs = new List(); + var attacks = new List(); + var combat = new List(); foreach (uint spellId in spellbook.LearnedSpells) { if (!spellbook.TryGetMetadata(spellId, out SpellMetadata meta)) continue; + if (meta.IsOffensive || meta.IsDebuff) + combat.Add(Project(meta)); // Beneficial and not a debuff is the whole filter. Requiring the // self-targeted flag here is what hid every bane: they are cast by // selecting yourself, and the flag only says "needs no selection". // Whether a given target accepts the spell is EvaluateGate's job. if (!meta.IsBeneficial || meta.IsDebuff || meta.IsUntargeted) + { + // MT1 intentionally projects direct attacks only. Rings, + // debuffs, streaks and harm/martyr policy are MT2, but they + // remain present in TryGet so later policy can inspect them. + if (meta.IsOffensive + && !meta.IsDebuff + && !meta.IsBeneficial + && !meta.IsSelfTargeted + && !meta.IsUntargeted + && meta.TargetMask != 0u) + { + attacks.Add(Project(meta)); + } continue; - built.Add(Project(meta)); + } + buffs.Add(Project(meta)); } - built.Sort(static (a, b) => + buffs.Sort(static (a, b) => a.Family != b.Family ? a.Family.CompareTo(b.Family) : b.Tier.CompareTo(a.Tier)); - _knownSelfBuffs = built; + attacks.Sort(static (a, b) => + { + int tier = b.Tier.CompareTo(a.Tier); + return tier != 0 + ? tier + : b.Difficulty.CompareTo(a.Difficulty); + }); + _knownSelfBuffs = buffs; + _knownAttackSpells = attacks; + combat.Sort(static (a, b) => + { + int tier = b.Tier.CompareTo(a.Tier); + return tier != 0 + ? tier + : string.CompareOrdinal(a.Name, b.Name); + }); + _knownCombatSpells = combat; } private void RebuildEnchantments() @@ -219,7 +750,23 @@ internal sealed class AppAutomationSurface SchoolSkillId(meta.SchoolId), meta.Description, meta.IsSelfTargeted, - meta.IsBeneficial); + meta.IsBeneficial) + { + IsDebuff = meta.IsDebuff, + IsOffensive = meta.IsOffensive, + IsFellowship = meta.IsFellowship, + IsUntargeted = meta.IsUntargeted, + RequiresTurnTo = meta.Family is not (>= 222u and <= 235u) + && !meta.IsUntargeted, + IsProjectile = meta.IsProjectile, + IsDamageOverTime = (meta.Flags & (uint)SpellFlags.DamageOverTime) != 0, + RawFlags = meta.Flags, + SpellType = meta.SpellType, + TargetMask = meta.TargetMask, + BaseRangeConstant = meta.BaseRangeConstant, + BaseRangeModifier = meta.BaseRangeModifier, + FormulaComponentIds = meta.FormulaComponents, + }; /// /// Magic school to the SKILL id that governs it. MagicSchool is @@ -240,6 +787,95 @@ internal sealed class AppAutomationSurface // ── ICharacterInfo ──────────────────────────────────────────────────── public bool IsInWorld => IsAvailable; + public string Name + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null) + return string.Empty; + uint playerId = runtime.PlayerIdentity.ServerGuid; + return runtime.InventoryOwner.Objects.Get(playerId)?.Name + ?? string.Empty; + } + } + + public string WorldName + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.CharacterSelection.Snapshot.WorldName ?? string.Empty; + } + } + + public string AccountName + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.CharacterSelection.Snapshot.AccountName ?? string.Empty; + } + } + + public int CharacterIndex + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null + || !runtime.CharacterSelection.TryGet( + runtime.PlayerIdentity.ServerGuid, + out RuntimeCharacterSelectionEntry character)) + { + return -1; + } + return character.ActiveIndex; + } + } + + public int Level + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null) + return 0; + return runtime.InventoryOwner.Objects + .Get(runtime.PlayerIdentity.ServerGuid)? + .Properties.GetInt((uint)PropertyInt.Level) ?? 0; + } + } + + public int MainPackFreeSlots + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null) + return 0; + uint playerId = runtime.PlayerIdentity.ServerGuid; + int occupied = runtime.InventoryOwner.Objects.Objects.Count(item => + item.ContainerId == playerId + && ClassifyObject(item) is not ( + PluginObjectClass.Container or PluginObjectClass.Foci) + && item.CurrentlyEquippedLocation == 0); + return Math.Max(0, 102 - occupied); + } + } + public uint ObjectId { get @@ -257,6 +893,20 @@ internal sealed class AppAutomationSurface public uint MaxStamina => Vital(LocalPlayerState.VitalKind.Stamina).Maximum; public uint CurrentMana => Vital(LocalPlayerState.VitalKind.Mana).Current; public uint MaxMana => Vital(LocalPlayerState.VitalKind.Mana).Maximum; + public int SummoningMastery + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null) + return 0; + uint playerId = runtime.PlayerIdentity.ServerGuid; + return runtime.InventoryOwner.Objects.Get(playerId)?.Properties.GetInt( + (uint)PropertyInt.SummoningMastery) ?? 0; + } + } private (uint Current, uint Maximum) Vital(LocalPlayerState.VitalKind kind) { @@ -325,8 +975,16 @@ internal sealed class AppAutomationSurface skill = default; return false; } + uint baseLevel = snapshot.CurrentLevel; + uint currentLevel = checked((uint)Math.Max( + 0, + character.LocalPlayer.GetEffectiveSkill(skillId) + ?? checked((int)baseLevel))); skill = new PluginSkillInfo( - skillId, name, Training(snapshot.Status), snapshot.CurrentLevel); + skillId, name, Training(snapshot.Status), currentLevel) + { + Base = baseLevel, + }; return true; } @@ -357,8 +1015,18 @@ internal sealed class AppAutomationSurface for (int kind = 0; kind < AttributeNames.Length; kind++) { if (character.View.TryGetAttribute(kind, out var attribute)) + { + uint effective = checked((uint)Math.Max( + 0, + character.LocalPlayer.GetEffectiveAttribute( + (LocalPlayerState.AttributeKind)kind) + ?? checked((int)attribute.Current))); built.Add(new PluginAttributeInfo( - kind, AttributeNames[kind], attribute.Current)); + kind, AttributeNames[kind], effective) + { + Base = attribute.Current, + }); + } } return built; } @@ -366,6 +1034,16 @@ internal sealed class AppAutomationSurface // ── ISpellCatalog ───────────────────────────────────────────────────── public IReadOnlyList KnownSelfBuffs => _knownSelfBuffs; + public IReadOnlyList KnownAttackSpells => _knownAttackSpells; + public IReadOnlyList KnownCombatSpells => _knownCombatSpells; + + public bool IsKnown(uint spellId) + { + Spellbook? spellbook; + lock (_gate) + spellbook = _spellbook; + return spellbook?.LearnedSpells.Contains(spellId) == true; + } public bool TryGet(uint spellId, out PluginSpellInfo info) { @@ -382,7 +1060,93 @@ internal sealed class AppAutomationSurface return false; } + public bool TryGetComponent(uint componentId, out PluginSpellComponentInfo info) + { + MagicCatalog catalog; + lock (_gate) + catalog = _magicCatalog; + if (catalog.TryGetComponentBySpellComponentId( + componentId, + out SpellComponentDescriptor descriptor)) + { + info = new PluginSpellComponentInfo( + descriptor.SpellComponentId, + descriptor.WeenieClassId, + descriptor.Name, + descriptor.BurnRate, + descriptor.GestureId, + descriptor.GestureSpeed, + descriptor.IconId, + descriptor.Category, + descriptor.Type, + descriptor.Word); + return true; + } + info = default; + return false; + } + // ── IPluginChat ─────────────────────────────────────────────────────── + public IReadOnlyList CaptureMessages(ulong afterSequence) + { + lock (_gate) + { + if (_chatMessages.Count == 0) + return Array.Empty(); + var result = new List(); + foreach (PluginChatMessage message in _chatMessages) + { + if (message.Sequence > afterSequence) + result.Add(message); + } + return result.Count == 0 + ? Array.Empty() + : result.ToArray(); + } + } + + public double GetCooldownRemaining(uint cooldownId) + { + Spellbook? spellbook; + GameRuntime? runtime; + lock (_gate) + { + spellbook = _spellbook; + runtime = _runtime; + } + if (spellbook is null || runtime is null || cooldownId == 0u) + return 0d; + return spellbook.OnCooldown( + cooldownId, + runtime.Clock.SimulationTimeSeconds, + out double remaining) + ? Math.Max(0d, remaining) + : 0d; + } + + public void OnChat(in RuntimeCommunicationEvent delta) + { + lock (_gate) + { + if (_disposed || _communication is null) + return; + RuntimeChatEntry entry = delta.Entry; + _chatMessages.Add(new PluginChatMessage( + ++_pluginChatSequence, + entry.SenderGuid, + entry.Kind, + entry.Sender, + entry.Text, + entry.ChannelName)); + if (_chatMessages.Count > MaximumPluginChatMessages) + { + _chatMessages.RemoveRange( + 0, + _chatMessages.Count - MaximumPluginChatMessages); + } + } + } + /// /// Routed to retail's ClientLocal log type (0x1A) — the channel the client /// uses for its own notices. Nothing reaches the server, so a plugin cannot @@ -398,6 +1162,842 @@ internal sealed class AppAutomationSurface communication?.AddText(text, RetailLogTextType.ClientLocal); } + public bool Submit(string text) + { + CurrentGameRuntimeAdapter? commands; + lock (_gate) + commands = _sessionCommands; + return commands?.SubmitChatText(text) == true; + } + + bool ISelectionAutomation.Execute(PluginSelectionAction action) + { + Func? execute; + lock (_gate) + execute = _disposed ? null : _selectionAction; + return execute?.Invoke(action) == true; + } + + // ── IProjectileAutomation ─────────────────────────────────────────── + bool IProjectileAutomation.IsAvailable + { + get + { + lock (_gate) + return !_disposed && _projectilePhysics is not null && IsAvailable; + } + } + + PluginProjectilePathResult IProjectileAutomation.EvaluatePath( + uint targetObjectId, + PluginProjectilePathKind kind, + PluginAttackHeight targetHeight, + float projectileRadius, + float stepDistance, + int maximumCollisionChecks) => EvaluateProjectilePathRequest( + targetObjectId, + kind, + targetHeight, + projectileRadius, + stepDistance, + maximumCollisionChecks, + captureDiagnostics: false); + + PluginProjectilePathResult IProjectileAutomation.EvaluatePathWithDiagnostics( + uint targetObjectId, + PluginProjectilePathKind kind, + PluginAttackHeight targetHeight, + float projectileRadius, + float stepDistance, + int maximumCollisionChecks) => EvaluateProjectilePathRequest( + targetObjectId, + kind, + targetHeight, + projectileRadius, + stepDistance, + maximumCollisionChecks, + captureDiagnostics: true); + + void IProjectileAutomation.ShowDebugSamples( + IReadOnlyList samples) + { + ArgumentNullException.ThrowIfNull(samples); + const int maximumMarkers = 4096; + var detached = new List( + Math.Min(samples.Count, maximumMarkers)); + for (int index = 0; index < samples.Count && index < maximumMarkers; index++) + { + PluginProjectileDebugSample sample = samples[index]; + if (!float.IsFinite(sample.WorldPosition.X) + || !float.IsFinite(sample.WorldPosition.Y) + || !float.IsFinite(sample.WorldPosition.Z) + || !float.IsFinite(sample.Radius) + || sample.Radius <= 0f) + { + continue; + } + detached.Add(sample); + } + lock (_gate) + { + if (_disposed) + return; + _projectileDebugSamples = detached.Count == 0 + ? Array.Empty() + : detached.ToArray(); + // VTank rebuilds these shapes on every collision pass. A short + // grace interval keeps them visible between automation ticks + // without turning one query into persistent world state. + _projectileDebugSamplesExpireAt = Environment.TickCount64 + 350; + } + } + + internal IReadOnlyList + CaptureProjectileDebugSamples() + { + lock (_gate) + { + if (_disposed + || Environment.TickCount64 > _projectileDebugSamplesExpireAt) + { + return Array.Empty(); + } + return _projectileDebugSamples; + } + } + + private PluginProjectilePathResult EvaluateProjectilePathRequest( + uint targetObjectId, + PluginProjectilePathKind kind, + PluginAttackHeight targetHeight, + float projectileRadius, + float stepDistance, + int maximumCollisionChecks, + bool captureDiagnostics) + { + GameRuntime? runtime; + PhysicsEngine? physics; + lock (_gate) + { + runtime = _runtime; + physics = _projectilePhysics; + } + if (runtime is null || physics is null || !IsAvailable) + return new(PluginProjectilePathStatus.Unavailable); + if (targetObjectId == 0u + || !float.IsFinite(projectileRadius) + || projectileRadius <= 0f + || !float.IsFinite(stepDistance) + || stepDistance <= 0f + || maximumCollisionChecks <= 0) + { + return new(PluginProjectilePathStatus.InvalidTarget); + } + + uint localId = runtime.PlayerIdentity.ServerGuid; + if (!runtime.EntityObjects.Entities.TryGetActive( + localId, + out RuntimeEntityRecord local) + || !runtime.EntityObjects.Entities.TryGetActive( + targetObjectId, + out RuntimeEntityRecord target) + || local.PhysicsBody is not { } localBody + || target.PhysicsBody is not { } targetBody + || localBody.CellPosition.ObjCellId == 0u) + { + return new(PluginProjectilePathStatus.InvalidTarget); + } + + try + { + return EvaluateProjectilePath( + physics, + localId, + localBody, + targetObjectId, + targetBody, + kind, + targetHeight, + projectileRadius, + stepDistance, + maximumCollisionChecks, + captureDiagnostics); + } + catch (Exception error) + { + return new( + PluginProjectilePathStatus.Error, + Notice: error.GetBaseException().Message); + } + } + + private static PluginProjectilePathResult EvaluateProjectilePath( + PhysicsEngine physics, + uint localObjectId, + PhysicsBody local, + uint targetObjectId, + PhysicsBody target, + PluginProjectilePathKind kind, + PluginAttackHeight targetHeight, + float radius, + float stepDistance, + int maximumChecks, + bool captureDiagnostics) + { + System.Numerics.Vector3 baseDelta = target.Position - local.Position; + var horizontal = new System.Numerics.Vector2(baseDelta.X, baseDelta.Y); + float horizontalDistance = horizontal.Length(); + if (!float.IsFinite(horizontalDistance) + || horizontalDistance <= PhysicsGlobals.EPSILON) + { + return new(PluginProjectilePathStatus.InvalidTarget); + } + + System.Numerics.Vector2 direction = horizontal / horizontalDistance; + float sourceForward = kind switch + { + PluginProjectilePathKind.Arc => 0.44f, + PluginProjectilePathKind.Missile => 0.61f, + _ => 0.66f, + }; + float sourceHeight = kind == PluginProjectilePathKind.Arc ? 1.8f : 1.2f; + float targetHeightMeters = targetHeight switch + { + PluginAttackHeight.Low => 0.3f, + PluginAttackHeight.High => 1.5f, + _ => 0.9f, + }; + var current = local.Position + new System.Numerics.Vector3( + direction.X * sourceForward, + direction.Y * sourceForward, + sourceHeight); + var destination = target.Position + new System.Numerics.Vector3( + 0f, + 0f, + targetHeightMeters); + System.Numerics.Vector3 delta = destination - current; + horizontal = new System.Numerics.Vector2(delta.X, delta.Y); + horizontalDistance = horizontal.Length(); + if (horizontalDistance <= PhysicsGlobals.EPSILON) + return new(PluginProjectilePathStatus.Clear); + direction = horizontal / horizontalDistance; + + float speed = kind switch + { + PluginProjectilePathKind.Arc => 37.5185f, + PluginProjectilePathKind.Missile => 46f, + _ => 100f, + }; + float totalTime = horizontalDistance / speed; + float verticalSpeed = kind == PluginProjectilePathKind.Straight + ? delta.Z / totalTime + : (delta.Z + 4.9f * totalTime * totalTime) / totalTime; + var velocity = new System.Numerics.Vector3( + direction.X * speed, + direction.Y * speed, + verticalSpeed); + float elapsed = 0f; + uint cellId = local.CellPosition.ObjCellId; + var probeBody = new PhysicsBody + { + State = PhysicsStateFlags.Missile + | PhysicsStateFlags.Inelastic + | PhysicsStateFlags.ReportCollisions, + }; + List? debugSamples = captureDiagnostics + ? new List( + Math.Min(maximumChecks, 512)) + : null; + + for (int check = 1; check <= maximumChecks; check++) + { + float remaining = MathF.Max(0f, totalTime - elapsed); + if (remaining <= PhysicsGlobals.EPSILON) + { + return WithProjectileDebugSamples( + new(PluginProjectilePathStatus.Clear, check - 1), + debugSamples); + } + float velocityMagnitude = velocity.Length(); + if (!float.IsFinite(velocityMagnitude) + || velocityMagnitude <= PhysicsGlobals.EPSILON) + { + return WithProjectileDebugSamples( + new( + PluginProjectilePathStatus.Error, + check - 1, + Notice: "The projectile trajectory became invalid."), + debugSamples); + } + float quantum = MathF.Min(remaining, stepDistance / velocityMagnitude); + System.Numerics.Vector3 next = current + velocity * quantum; + if (quantum >= remaining - PhysicsGlobals.EPSILON) + next = destination; + + ResolveResult resolved = physics.ResolveWithTransition( + current, + next, + cellId, + radius, + sphereHeight: 0f, + stepUpHeight: 0f, + stepDownHeight: 0f, + isOnGround: false, + body: probeBody, + moverFlags: ObjectInfoState.PathClipped, + movingEntityId: localObjectId, + localSphereOrigin: System.Numerics.Vector3.Zero, + designatedTargetId: targetObjectId); + float requestedDistance = System.Numerics.Vector3.Distance(current, next); + float deliveredDistance = System.Numerics.Vector3.Distance( + current, + resolved.Position); + bool stopped = !resolved.Ok + || resolved.CollidedWithEnvironment + || resolved.LastCollidedObjectId != 0u + || resolved.CollisionNormalValid + || deliveredDistance + 0.01f < requestedDistance; + bool targetHit = resolved.LastCollidedObjectId == targetObjectId; + debugSamples?.Add(new PluginProjectileDebugSample( + resolved.Position, + targetHit || !stopped, + radius)); + if (targetHit) + { + return WithProjectileDebugSamples( + new( + PluginProjectilePathStatus.Clear, + check, + targetObjectId), + debugSamples); + } + if (stopped) + { + return WithProjectileDebugSamples( + new( + PluginProjectilePathStatus.Blocked, + check, + resolved.LastCollidedObjectId), + debugSamples); + } + + current = resolved.Position; + cellId = resolved.CellId; + elapsed += quantum; + if (kind != PluginProjectilePathKind.Straight) + velocity.Z -= 9.8f * quantum; + } + + return WithProjectileDebugSamples( + new( + PluginProjectilePathStatus.BudgetExceeded, + maximumChecks, + Notice: "The projectile collision-check budget was exhausted."), + debugSamples); + } + + private static PluginProjectilePathResult WithProjectileDebugSamples( + PluginProjectilePathResult result, + List? samples) => samples is null + ? result + : result with { DebugSamples = samples.ToArray() }; + + // ── INavigationAutomation ───────────────────────────────────────────── + PluginNavigationSnapshot INavigationAutomation.Snapshot + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return default; + + RuntimeMovementSnapshot movement = runtime.Movement.Snapshot; + if (!movement.HasController) + return default; + RuntimePortalSnapshot portal = runtime.Portal.Snapshot; + PluginNavigationPosition livePosition = + ProjectNavigationPosition(movement.Position); + PluginNavigationPosition confirmedPosition = livePosition; + ulong confirmedRevision = 0UL; + if (runtime.EntityObjects.Entities.TryGetActive( + runtime.PlayerIdentity.ServerGuid, + out RuntimeEntityRecord localRecord) + && ConvertPosition(localRecord.Snapshot.Position) is { } accepted) + { + confirmedPosition = ProjectNavigationPosition(accepted); + confirmedRevision = localRecord.PositionAuthorityVersion; + } + return new PluginNavigationSnapshot( + IsAvailable: true, + IsPortalSpace: portal.Kind != RuntimePortalKind.None + && !portal.Completed + && !portal.Cancelled, + LocalObjectId: runtime.PlayerIdentity.ServerGuid, + Position: livePosition, + IsMoving: movement.Velocity.LengthSquared() > 0.0001f + || movement.HasCommandInput, + IsAirborne: movement.IsAirborne) + { + ConfirmedPosition = confirmedPosition, + ConfirmedPositionRevision = confirmedRevision, + }; + } + } + + public bool TryGetObject(uint objectId, out PluginNavigationObject value) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable || objectId == 0u) + { + value = default; + return false; + } + + RuntimeMovementSnapshot movement = runtime.Movement.Snapshot; + if (objectId == runtime.PlayerIdentity.ServerGuid) + { + value = new PluginNavigationObject( + objectId, + runtime.InventoryOwner.Objects.Get(objectId)?.Name + ?? string.Empty, + ProjectNavigationPosition(movement.Position)); + return movement.HasController; + } + + if (!runtime.EntityObjects.Entities.TryGetActive( + objectId, + out RuntimeEntityRecord record)) + { + value = default; + return false; + } + + Position? position = record.PhysicsBody?.CellPosition + ?? ConvertPosition(record.Snapshot.Position); + if (position is not { } current) + { + value = default; + return false; + } + value = new PluginNavigationObject( + objectId, + runtime.InventoryOwner.Objects.Get(objectId)?.Name + ?? record.Snapshot.Name + ?? $"0x{objectId:X8}", + ProjectNavigationPosition(current)); + value = EnrichNavigationObject( + value, + runtime.InventoryOwner.Objects.Get(objectId)); + return true; + } + + public bool TryFindObject( + string name, + in PluginNavigationPosition near, + double maximumDistanceMeters, + out PluginNavigationObject value) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null + || !IsAvailable + || string.IsNullOrWhiteSpace(name) + || !double.IsFinite(maximumDistanceMeters) + || maximumDistanceMeters < 0d) + { + value = default; + return false; + } + + double nearestDistance = maximumDistanceMeters; + PluginNavigationObject nearest = default; + bool found = false; + foreach (RuntimeEntityRecord record in runtime.EntityObjects.Entities.ActiveRecords) + { + uint objectId = record.ServerGuid; + string candidateName = runtime.InventoryOwner.Objects.Get(objectId)?.Name + ?? record.Snapshot.Name + ?? string.Empty; + if (!candidateName.Equals(name, StringComparison.OrdinalIgnoreCase)) + continue; + + Position? source = record.PhysicsBody?.CellPosition + ?? ConvertPosition(record.Snapshot.Position); + if (source is not { } position) + continue; + PluginNavigationPosition candidate = ProjectNavigationPosition(position); + double distance = near.HorizontalDistanceMeters(candidate); + if (distance > nearestDistance) + continue; + + nearestDistance = distance; + nearest = EnrichNavigationObject( + new PluginNavigationObject(objectId, candidateName, candidate), + runtime.InventoryOwner.Objects.Get(objectId)); + found = true; + } + + value = nearest; + return found; + } + + public IReadOnlyList CaptureObjects() + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return Array.Empty(); + + var result = new List(); + foreach (RuntimeEntityRecord record in runtime.EntityObjects.Entities.ActiveRecords) + { + Position? source = record.PhysicsBody?.CellPosition + ?? ConvertPosition(record.Snapshot.Position); + if (source is not { } position) + continue; + ClientObject? item = runtime.InventoryOwner.Objects.Get(record.ServerGuid); + string name = item?.Name + ?? record.Snapshot.Name + ?? $"0x{record.ServerGuid:X8}"; + result.Add(EnrichNavigationObject( + new PluginNavigationObject( + record.ServerGuid, + name, + ProjectNavigationPosition(position)), + item)); + } + result.Sort(static (left, right) => left.ObjectId.CompareTo(right.ObjectId)); + return result; + } + + public PluginNavigationCommandStatus SetMovementIntent( + in PluginMovementIntent intent) + { + CurrentGameRuntimeAdapter? commands; + lock (_gate) + commands = _sessionCommands; + if (commands is null || !IsAvailable) + return PluginNavigationCommandStatus.Unavailable; + RuntimeCommandResult result = commands.MovementCommands.SetIntent( + commands.Generation, + new MovementInput( + intent.Forward, + intent.Backward, + intent.StrafeLeft, + intent.StrafeRight, + intent.TurnLeft, + intent.TurnRight, + intent.Run, + MouseDeltaX: 0f, + intent.Jump)); + return result.Status == RuntimeCommandStatus.Accepted + ? PluginNavigationCommandStatus.Accepted + : PluginNavigationCommandStatus.Rejected; + } + + public PluginNavigationCommandStatus ClearMovementIntent() + { + CurrentGameRuntimeAdapter? commands; + lock (_gate) + commands = _sessionCommands; + if (commands is null || !IsAvailable) + return PluginNavigationCommandStatus.Unavailable; + RuntimeCommandResult result = commands.MovementCommands.ClearIntent( + commands.Generation); + return result.Status == RuntimeCommandStatus.Accepted + ? PluginNavigationCommandStatus.Accepted + : PluginNavigationCommandStatus.Rejected; + } + + internal static PluginNavigationPosition ProjectNavigationPosition( + Position position) + { + uint cellId = position.ObjCellId; + uint blockX = (cellId >> 24) & 0xFFu; + uint blockY = (cellId >> 16) & 0xFFu; + System.Numerics.Vector3 local = position.Frame.Origin; + return new PluginNavigationPosition( + cellId, + (((double)blockX - 127d) * 192d + local.X - 84d) / 240d, + (((double)blockY - 127d) * 192d + local.Y - 84d) / 240d, + local.Z / 240d, + MoveToMath.GetHeading(position.Frame.Orientation), + (cellId & 0xFFFFu) is >= 1u and <= 0x40u); + } + + // ── IWorldObjectAutomation ──────────────────────────────────────────── + bool IWorldObjectAutomation.IsAvailable => IsAvailable; + + uint IWorldObjectAutomation.OpenContainerObjectId + { + get + { + lock (_gate) + return _runtime?.InventoryOwner.ExternalContainers + .CurrentContainerId ?? 0u; + } + } + + IReadOnlyList IWorldObjectAutomation.CaptureObjects() + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return Array.Empty(); + + ClientObjectTable objects = runtime.InventoryOwner.Objects; + uint playerId = runtime.PlayerIdentity.ServerGuid; + var result = new List(); + var captured = new HashSet(); + foreach (RuntimeEntityRecord record in + runtime.EntityObjects.Entities.ActiveRecords.ToArray()) + { + ClientObject? item = objects.Get(record.ServerGuid); + result.Add(ProjectWorldObject(runtime, record, item, playerId)); + captured.Add(record.ServerGuid); + } + foreach (ClientObject item in objects.Objects) + { + if (!captured.Add(item.ObjectId)) + continue; + result.Add(ProjectWorldObject(runtime, null, item, playerId)); + } + result.Sort(static (left, right) => left.ObjectId.CompareTo(right.ObjectId)); + return result; + } + + bool IWorldObjectAutomation.TryGet( + uint objectId, + out PluginWorldObject value) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable || objectId == 0u) + { + value = default; + return false; + } + + runtime.EntityObjects.Entities.TryGetActive( + objectId, + out RuntimeEntityRecord? record); + ClientObject? item = runtime.InventoryOwner.Objects.Get(objectId); + if (record is null && item is null) + { + value = default; + return false; + } + value = ProjectWorldObject( + runtime, + record, + item, + runtime.PlayerIdentity.ServerGuid); + return true; + } + + bool IWorldObjectAutomation.TryCaptureProperties( + uint objectId, + out PluginItemProperties properties) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + ClientObject? item = runtime?.InventoryOwner.Objects.Get(objectId); + if (runtime is null || !IsAvailable || item is null) + { + properties = default; + return false; + } + properties = CaptureProperties(item.Properties); + return true; + } + + PluginItemCommandResult IWorldObjectAutomation.Identify(uint objectId) => + ((ILootAutomation)this).Identify(objectId); + + private PluginWorldObject ProjectWorldObject( + GameRuntime runtime, + RuntimeEntityRecord? record, + ClientObject? item, + uint playerId) + { + uint objectId = record?.ServerGuid ?? item!.ObjectId; + Position? source = record?.PhysicsBody?.CellPosition + ?? (record is null ? null : ConvertPosition(record.Snapshot.Position)); + bool owned = item is not null + && IsPlayerOwned(item, playerId, runtime.InventoryOwner.Objects); + IReadOnlyList activeSpells = objectId == playerId + ? _enchantments.Select(static enchantment => enchantment.SpellId).ToArray() + : Array.Empty(); + uint publicFlags = item?.PublicWeenieBitfield ?? 0u; + return new PluginWorldObject( + objectId, + item?.WeenieClassId ?? 0u, + item?.Name ?? record?.Snapshot.Name ?? $"0x{objectId:X8}", + ClassifyObject(item), + (uint)(item?.Type ?? ItemType.None), + item?.ContainerId ?? 0u, + item?.WielderId ?? 0u) + { + IsOwned = owned, + IsLandscape = source is not null + && !owned + && (item?.ContainerId ?? 0u) == 0u + && (item?.WielderId ?? 0u) == 0u, + HasPosition = source is not null, + Position = source is { } position + ? ProjectNavigationPosition(position) + : default, + HasAppraisalData = item is not null && HasPropertyData(item.Properties), + LastIdTime = item?.LastAppraisalTimeMs ?? 0, + IsDoorOpen = (publicFlags & (uint)PublicWeenieFlags.Door) != 0u + && (item?.Properties.GetBool((uint)PropertyBool.Open) ?? false), + StackSize = Math.Max(1, item?.StackSize ?? 1), + ItemsCapacity = item?.ItemsCapacity ?? 0, + ContainersCapacity = item?.ContainersCapacity ?? 0, + SpellIds = item?.AppraisedSpellIds.Count > 0 + ? item.AppraisedSpellIds.ToArray() + : Array.Empty(), + ActiveSpellIds = activeSpells, + }; + } + + private static bool HasPropertyData(PropertyBundle properties) => + properties.Ints.Count != 0 + || properties.Int64s.Count != 0 + || properties.Bools.Count != 0 + || properties.Floats.Count != 0 + || properties.Strings.Count != 0 + || properties.DataIds.Count != 0 + || properties.InstanceIds.Count != 0; + + /// + /// Exact Virindi/Decal ObjectClass priority from VTank's fu.a(). + /// PublicWeenieDesc flags override ItemType, then writable and creature + /// refinements distinguish books/scrolls/NPCs/combat pets. + /// + internal static PluginObjectClass ClassifyObject(ClientObject? item) + { + if (item is null) + return PluginObjectClass.Unknown; + uint type = (uint)item.Type; + uint flags = item.PublicWeenieBitfield ?? 0u; + PluginObjectClass result = type switch + { + _ when (type & 0x00000001u) != 0u => PluginObjectClass.MeleeWeapon, + _ when (type & 0x00000002u) != 0u => PluginObjectClass.Armor, + _ when (type & 0x00000004u) != 0u => PluginObjectClass.Clothing, + _ when (type & 0x00000008u) != 0u => PluginObjectClass.Jewelry, + _ when (type & 0x00000010u) != 0u => PluginObjectClass.Monster, + _ when (type & 0x00000020u) != 0u => PluginObjectClass.Food, + _ when (type & 0x00000040u) != 0u => PluginObjectClass.Money, + _ when (type & 0x00000080u) != 0u => PluginObjectClass.Misc, + _ when (type & 0x00000100u) != 0u => PluginObjectClass.MissileWeapon, + _ when (type & 0x00000200u) != 0u => PluginObjectClass.Container, + _ when (type & 0x00000400u) != 0u => PluginObjectClass.Bundle, + _ when (type & 0x00000800u) != 0u => PluginObjectClass.Gem, + _ when (type & 0x00001000u) != 0u => PluginObjectClass.SpellComponent, + _ when (type & 0x00004000u) != 0u => PluginObjectClass.Key, + _ when (type & 0x00008000u) != 0u => PluginObjectClass.WandStaffOrb, + _ when (type & 0x00010000u) != 0u => PluginObjectClass.Portal, + _ when (type & 0x00040000u) != 0u => PluginObjectClass.TradeNote, + _ when (type & 0x00080000u) != 0u => PluginObjectClass.ManaStone, + _ when (type & 0x00100000u) != 0u => PluginObjectClass.Services, + _ when (type & 0x00200000u) != 0u => PluginObjectClass.Plant, + _ when (type & 0x00400000u) != 0u => PluginObjectClass.BaseCooking, + _ when (type & 0x00800000u) != 0u => PluginObjectClass.BaseAlchemy, + _ when (type & 0x01000000u) != 0u => PluginObjectClass.BaseFletching, + _ when (type & 0x02000000u) != 0u => PluginObjectClass.CraftedCooking, + _ when (type & 0x04000000u) != 0u => PluginObjectClass.CraftedAlchemy, + _ when (type & 0x08000000u) != 0u => PluginObjectClass.CraftedFletching, + _ when (type & 0x20000000u) != 0u => PluginObjectClass.Ust, + _ when (type & 0x40000000u) != 0u => PluginObjectClass.Salvage, + _ => PluginObjectClass.Unknown, + }; + + result = flags switch + { + _ when (flags & 0x00000008u) != 0u => PluginObjectClass.Player, + _ when (flags & 0x00000200u) != 0u => PluginObjectClass.Vendor, + _ when (flags & 0x00001000u) != 0u => PluginObjectClass.Door, + _ when (flags & 0x00002000u) != 0u => PluginObjectClass.Corpse, + _ when (flags & 0x00004000u) != 0u => PluginObjectClass.Lifestone, + _ when (flags & 0x00008000u) != 0u => PluginObjectClass.Food, + _ when (flags & 0x00010000u) != 0u => PluginObjectClass.HealingKit, + _ when (flags & 0x00020000u) != 0u => PluginObjectClass.Lockpick, + _ when (flags & 0x00040000u) != 0u => PluginObjectClass.Portal, + _ when (flags & 0x00800000u) != 0u => PluginObjectClass.Foci, + _ when (flags & 0x00000001u) != 0u => PluginObjectClass.Container, + _ => result, + }; + + if ((type & 0x00002000u) != 0u && result == PluginObjectClass.Unknown) + { + result = (flags & 0x00000002u) != 0u + ? PluginObjectClass.Journal + : (flags & 0x00000004u) != 0u + ? PluginObjectClass.Sign + : (flags & 0x0000000Fu) != 0u + ? PluginObjectClass.Book + : result; + } + if ((type & 0x00002000u) != 0u && item.SpellId is > 0u) + result = PluginObjectClass.Scroll; + if (result == PluginObjectClass.Monster && (flags & 0x10u) == 0u) + result = PluginObjectClass.Npc; + if (result == PluginObjectClass.Monster && (flags & 0x04000000u) != 0u) + result = PluginObjectClass.CombatPet; + return result; + } + + private static PluginNavigationObject EnrichNavigationObject( + in PluginNavigationObject value, + ClientObject? item) + { + if (item is null) + return value; + bool hasOpen = item.Properties.Bools.TryGetValue( + (uint)PropertyBool.Open, + out bool isOpen); + bool hasLocked = item.Properties.Bools.TryGetValue( + (uint)PropertyBool.Locked, + out bool isLocked); + return value with + { + IsDoor = ((PublicWeenieFlags)(item.PublicWeenieBitfield ?? 0u) + & PublicWeenieFlags.Door) != 0, + IsOpen = hasOpen && isOpen, + IsLocked = hasLocked && isLocked, + HasLockState = hasOpen || hasLocked, + LockDifficulty = item.Properties.GetInt( + (uint)PropertyInt.ResistLockpick), + }; + } + + private static Position? ConvertPosition( + AcDream.Core.Net.Messages.CreateObject.ServerPosition? position) => + position is not { } value + ? null + : new Position( + value.LandblockId, + new System.Numerics.Vector3( + value.PositionX, + value.PositionY, + value.PositionZ), + new System.Numerics.Quaternion( + value.RotationX, + value.RotationY, + value.RotationZ, + value.RotationW)); + // ── IMagicCommands ──────────────────────────────────────────────────── /// /// True while an action the server has not acknowledged is in flight. @@ -456,16 +2056,1693 @@ internal sealed class AppAutomationSurface return cast is not null && cast.Cast(spellId) == CastRequestResult.Sent; } + public PluginCastCompletion LastCompletion + { + get + { + ObserveSuccessfulLocalCast(); + RuntimeSpellCastState? cast; + lock (_gate) + cast = _cast; + RuntimeSpellCastCompletion completion = + cast?.LastCompletion ?? default; + return new PluginCastCompletion( + completion.Revision, + completion.SpellId, + completion.TargetObjectId, + completion.WeenieError); + } + } + + public PluginCastGate EvaluateGate(uint spellId, uint targetObjectId) + { + if (!SelectExplicitTarget(targetObjectId)) + return PluginCastGate.Refused; + return EvaluateGate(spellId); + } + + public bool Cast(uint spellId, uint targetObjectId) => + SelectExplicitTarget(targetObjectId) && Cast(spellId); + + // ── IEquipmentAutomation ───────────────────────────────────────────── + bool IEquipmentAutomation.IsAvailable + { + get + { + lock (_gate) + return !_disposed && _equip is not null && IsAvailable; + } + } + + bool IEquipmentAutomation.IsBusy + { + get + { + Func? busy; + lock (_gate) + busy = _equipmentBusy; + return busy?.Invoke() == true; + } + } + + public IReadOnlyList CaptureOwnedEquipment() + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return Array.Empty(); + + uint playerId = runtime.PlayerIdentity.ServerGuid; + if (playerId == 0u) + return Array.Empty(); + ClientObjectTable objects = runtime.InventoryOwner.Objects; + var built = new List(); + foreach (ClientObject item in objects.Objects) + { + if (item.ValidLocations == EquipMask.None + || !IsPlayerOwned(item, playerId, objects)) + { + continue; + } + built.Add(new PluginEquipmentItem( + item.ObjectId, + item.GetAppropriateName(), + (uint)item.Type, + (uint)item.ValidLocations, + (uint)item.CurrentlyEquippedLocation, + item.ContainerId, + item.WielderId, + item.CombatUse ?? 0, + item.Properties.GetInt((uint)PropertyInt.DamageType), + item.Properties.GetInt((uint)PropertyInt.WeaponSkill), + item.Properties.GetInt((uint)PropertyInt.Damage), + item.Properties.GetFloat((uint)PropertyFloat.DamageVariance)) + { + AmmoType = item.AmmoType ?? (uint)Math.Max( + 0, + item.Properties.GetInt((uint)PropertyInt.AmmoType)), + StackSize = Math.Max(1, item.StackSize), + WeaponType = item.Properties.GetInt( + (uint)PropertyInt.WeaponType), + }); + } + built.Sort(static (left, right) => + { + int equipped = right.IsEquipped.CompareTo(left.IsEquipped); + if (equipped != 0) + return equipped; + int name = string.CompareOrdinal(left.Name, right.Name); + return name != 0 + ? name + : left.ObjectId.CompareTo(right.ObjectId); + }); + return built; + } + + public PluginEquipmentCommandResult Equip( + uint objectId, + uint requestedLocation = 0u) + { + Func? equip; + Func? busy; + GameRuntime? runtime; + lock (_gate) + { + equip = _equip; + busy = _equipmentBusy; + runtime = _runtime; + } + if (equip is null || runtime is null || !IsAvailable) + return new(PluginEquipmentCommandStatus.Unavailable); + if (objectId == 0u + || runtime.InventoryOwner.Objects.Get(objectId) is not { } item + || item.ValidLocations == EquipMask.None) + { + return new(PluginEquipmentCommandStatus.InvalidItem); + } + if (item.CurrentlyEquippedLocation != EquipMask.None + && (requestedLocation == 0u + || ((uint)item.CurrentlyEquippedLocation & requestedLocation) + == requestedLocation)) + { + return new(PluginEquipmentCommandStatus.AlreadyEquipped); + } + if (busy?.Invoke() == true) + return new(PluginEquipmentCommandStatus.Busy); + return equip(objectId, requestedLocation) + ? new(PluginEquipmentCommandStatus.Started) + : new(PluginEquipmentCommandStatus.Refused); + } + + // ── IItemAutomation ────────────────────────────────────────────────── + bool IItemAutomation.IsAvailable + { + get + { + lock (_gate) + return !_disposed && _useItem is not null + && _applyItem is not null && IsAvailable; + } + } + + bool IItemAutomation.IsBusy + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime is not null + && !runtime.InventoryOwner.Transactions.CanBeginRequest; + } + } + + int IItemAutomation.ActiveOwnedPetCount + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return 0; + uint playerId = runtime.PlayerIdentity.ServerGuid; + int count = 0; + foreach (ClientObject candidate in runtime.InventoryOwner.Objects.Objects) + { + if (candidate.PetOwnerId == playerId + && (candidate.Type & ItemType.Creature) != 0) + { + count++; + } + } + return count; + } + } + + PluginItemUseCompletion IItemAutomation.LastCompletion + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + RuntimeItemUseCompletion completion = + runtime?.ActionOwner.Transactions.LastItemUseCompletion ?? default; + return new PluginItemUseCompletion( + completion.Revision, + completion.SourceObjectId, + completion.TargetObjectId, + completion.WeenieError); + } + } + + PluginInventoryCompletion IItemAutomation.LastInventoryCompletion + { + get + { + lock (_gate) + return _lastInventoryCompletion; + } + } + + public uint ActiveVendorObjectId + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.InventoryOwner.Vendor.VendorId ?? 0u; + } + } + + public IReadOnlyList CaptureOwnedItems() + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return Array.Empty(); + + uint playerId = runtime.PlayerIdentity.ServerGuid; + if (playerId == 0u) + return Array.Empty(); + ClientObjectTable objects = runtime.InventoryOwner.Objects; + var built = new List(); + foreach (ClientObject item in objects.Objects) + { + if (!IsPlayerOwned(item, playerId, objects)) + continue; + built.Add(new PluginInventoryItem( + item.ObjectId, + item.WeenieClassId, + item.GetAppropriateName(), + (uint)item.Type, + item.ContainerId, + item.WielderId, + (uint)item.ValidLocations, + (uint)item.CurrentlyEquippedLocation, + item.Useability ?? 0u, + item.TargetType ?? 0u, + item.PublicWeenieBitfield ?? 0u, + item.StackSize, + item.Structure, + item.MaxStructure, + item.SpellId + ?? (item.Properties.DataIds.TryGetValue( + (uint)PropertyDataId.Spell, + out uint itemSpell) ? itemSpell : 0u), + item.Properties.GetInt((uint)PropertyInt.PetClass), + item.Properties.GetInt((uint)PropertyInt.SummoningMastery), + item.Properties.DataIds.TryGetValue( + (uint)PropertyDataId.ProcSpell, + out uint procSpell) ? procSpell : 0u, + item.Properties.GetBool((uint)PropertyBool.ProcSpellSelfTargeted), + item.Properties.GetFloat((uint)PropertyFloat.ProcSpellRate), + item.Properties.GetInt((uint)PropertyInt.WeaponSkill), + item.Properties.GetInt((uint)PropertyInt.DamageType), + item.Properties.GetInt((uint)PropertyInt.Damage), + item.Properties.GetFloat((uint)PropertyFloat.DamageVariance), + item.Properties.GetInt((uint)PropertyInt.UseRequiresSkill), + item.Properties.GetInt((uint)PropertyInt.UseRequiresSkillLevel), + item.Properties.GetInt((uint)PropertyInt.UseRequiresSkillSpec)) + { + CombatUse = item.CombatUse ?? 0, + ItemSpellcraft = item.Properties.GetInt( + (uint)PropertyInt.ItemSpellcraft), + WieldRequirements = item.Properties.GetInt( + (uint)PropertyInt.WieldRequirements), + WieldSkillType = item.Properties.GetInt( + (uint)PropertyInt.WieldSkilltype), + WieldDifficulty = item.Properties.GetInt( + (uint)PropertyInt.WieldDifficulty), + AttackType = item.Properties.GetInt( + (uint)PropertyInt.AttackType), + WeaponType = item.Properties.GetInt( + (uint)PropertyInt.WeaponType), + BoosterVital = item.Properties.GetInt( + (uint)PropertyInt.BoosterEnum), + BoostValue = item.Properties.GetInt( + (uint)PropertyInt.BoostValue), + HealKitModifier = item.Properties.GetFloat( + (uint)PropertyFloat.HealkitMod), + AppraisedSpellIds = item.AppraisedSpellIds.Count == 0 + ? Array.Empty() + : item.AppraisedSpellIds.ToArray(), + GearDamage = item.Properties.GetInt((uint)PropertyInt.GearDamage), + GearDamageResistance = item.Properties.GetInt( + (uint)PropertyInt.GearDamageResist), + GearCriticalChance = item.Properties.GetInt( + (uint)PropertyInt.GearCrit), + GearCriticalResistance = item.Properties.GetInt( + (uint)PropertyInt.GearCritResist), + GearCriticalDamage = item.Properties.GetInt( + (uint)PropertyInt.GearCritDamage), + GearCriticalDamageResistance = item.Properties.GetInt( + (uint)PropertyInt.GearCritDamageResist), + MaximumStackSize = item.StackSizeMax, + ContainerSlot = item.ContainerSlot, + ItemsCapacity = item.ItemsCapacity, + ContainersCapacity = item.ContainersCapacity, + Burden = item.Burden, + Value = item.Value, + ItemCurrentMana = item.Properties.GetInt( + (uint)PropertyInt.ItemCurMana), + ItemMaximumMana = item.Properties.GetInt( + (uint)PropertyInt.ItemMaxMana), + Workmanship = item.Workmanship, + MaterialType = item.MaterialType ?? 0u, + ObjectClass = ClassifyObject(item), + Palettes = ProjectPalettes(runtime, item.ObjectId), + }); + } + built.Sort(static (left, right) => + { + int name = string.CompareOrdinal(left.Name, right.Name); + return name != 0 ? name : left.ObjectId.CompareTo(right.ObjectId); + }); + return built; + } + + public bool TryCaptureProperties( + uint objectId, + out PluginItemProperties properties) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + { + properties = default; + return false; + } + ClientObjectTable objects = runtime.InventoryOwner.Objects; + uint playerId = runtime.PlayerIdentity.ServerGuid; + if (!TryGetOwned(objects, playerId, objectId, out ClientObject? item)) + { + properties = default; + return false; + } + PropertyBundle source = item!.Properties; + properties = new PluginItemProperties( + new Dictionary(source.Ints), + new Dictionary(source.Int64s), + new Dictionary(source.Bools), + new Dictionary(source.Floats), + new Dictionary(source.Strings), + new Dictionary(source.DataIds), + new Dictionary(source.InstanceIds)); + return true; + } + + public PluginItemCommandResult Use(uint objectId) + => DispatchItem(objectId, 0u); + + public PluginItemCommandResult Apply(uint objectId, uint targetObjectId) + => DispatchItem(objectId, targetObjectId); + + public PluginItemCommandResult MoveToContainer( + uint objectId, + uint containerObjectId, + uint amount = 0u, + int placement = 0) + { + Func? move; + GameRuntime? runtime; + lock (_gate) + { + move = _moveItem; + runtime = _runtime; + } + if (runtime is null || move is null || !IsAvailable) + return new(PluginItemCommandStatus.Unavailable); + ClientObjectTable objects = runtime.InventoryOwner.Objects; + uint playerId = runtime.PlayerIdentity.ServerGuid; + if (!TryGetOwned(objects, playerId, objectId, out ClientObject? item)) + return new(PluginItemCommandStatus.InvalidItem); + if (containerObjectId == 0u + || objects.Get(containerObjectId) is not { } container + || (containerObjectId != playerId + && !IsPlayerOwned(container, playerId, objects))) + { + return new(PluginItemCommandStatus.InvalidTarget); + } + if (!ValidAmount(item!, amount)) + return new(PluginItemCommandStatus.Refused, "Invalid stack quantity."); + if (!runtime.InventoryOwner.Transactions.CanBeginRequest) + return new(PluginItemCommandStatus.Busy); + return move(objectId, containerObjectId, amount, placement) + ? new(PluginItemCommandStatus.Started) + : new(PluginItemCommandStatus.Refused); + } + + public PluginItemCommandResult Merge( + uint sourceObjectId, + uint targetObjectId, + uint amount = 0u) + { + Func? merge; + GameRuntime? runtime; + lock (_gate) + { + merge = _mergeItems; + runtime = _runtime; + } + if (runtime is null || merge is null || !IsAvailable) + return new(PluginItemCommandStatus.Unavailable); + ClientObjectTable objects = runtime.InventoryOwner.Objects; + uint playerId = runtime.PlayerIdentity.ServerGuid; + if (!TryGetOwned(objects, playerId, sourceObjectId, out ClientObject? source)) + return new(PluginItemCommandStatus.InvalidItem); + if (!TryGetOwned(objects, playerId, targetObjectId, out _)) + return new(PluginItemCommandStatus.InvalidTarget); + if (!ValidAmount(source!, amount)) + return new(PluginItemCommandStatus.Refused, "Invalid stack quantity."); + if (!runtime.InventoryOwner.Transactions.CanBeginRequest) + return new(PluginItemCommandStatus.Busy); + return merge(sourceObjectId, targetObjectId, amount) + ? new(PluginItemCommandStatus.Started) + : new(PluginItemCommandStatus.Refused); + } + + public PluginItemCommandResult Drop(uint objectId, uint amount = 0u) + { + Func? drop; + GameRuntime? runtime; + lock (_gate) + { + drop = _dropItem; + runtime = _runtime; + } + if (runtime is null || drop is null || !IsAvailable) + return new(PluginItemCommandStatus.Unavailable); + ClientObjectTable objects = runtime.InventoryOwner.Objects; + if (!TryGetOwned( + objects, + runtime.PlayerIdentity.ServerGuid, + objectId, + out ClientObject? item)) + { + return new(PluginItemCommandStatus.InvalidItem); + } + if (!ValidAmount(item!, amount)) + return new(PluginItemCommandStatus.Refused, "Invalid stack quantity."); + if (!runtime.InventoryOwner.Transactions.CanBeginRequest) + return new(PluginItemCommandStatus.Busy); + return drop(objectId, amount) + ? new(PluginItemCommandStatus.Started) + : new(PluginItemCommandStatus.Refused); + } + + public PluginItemCommandResult Give( + uint objectId, + uint targetObjectId, + uint amount = 0u) + { + Func? give; + GameRuntime? runtime; + lock (_gate) + { + give = _giveItem; + runtime = _runtime; + } + if (runtime is null || give is null || !IsAvailable) + return new(PluginItemCommandStatus.Unavailable); + ClientObjectTable objects = runtime.InventoryOwner.Objects; + if (!TryGetOwned( + objects, + runtime.PlayerIdentity.ServerGuid, + objectId, + out ClientObject? item)) + { + return new(PluginItemCommandStatus.InvalidItem); + } + if (targetObjectId == 0u || objects.Get(targetObjectId) is null) + return new(PluginItemCommandStatus.InvalidTarget); + if (!ValidAmount(item!, amount)) + return new(PluginItemCommandStatus.Refused, "Invalid stack quantity."); + if (!runtime.InventoryOwner.Transactions.CanBeginRequest) + return new(PluginItemCommandStatus.Busy); + return give(objectId, targetObjectId, amount) + ? new(PluginItemCommandStatus.Started) + : new(PluginItemCommandStatus.Refused); + } + + public PluginItemCommandResult Salvage( + uint toolObjectId, + IReadOnlyList itemObjectIds) + { + Func, bool>? salvage; + GameRuntime? runtime; + lock (_gate) + { + salvage = _salvageItems; + runtime = _runtime; + } + if (runtime is null || salvage is null || !IsAvailable) + return new(PluginItemCommandStatus.Unavailable); + if (itemObjectIds is null || itemObjectIds.Count == 0) + return new(PluginItemCommandStatus.InvalidItem); + + ClientObjectTable objects = runtime.InventoryOwner.Objects; + uint playerId = runtime.PlayerIdentity.ServerGuid; + if (!TryGetOwned(objects, playerId, toolObjectId, out ClientObject? tool) + || (tool!.Type & ItemType.TinkeringTool) == 0) + { + return new(PluginItemCommandStatus.InvalidTarget); + } + foreach (uint itemObjectId in itemObjectIds) + { + if (!TryGetOwned(objects, playerId, itemObjectId, out _)) + return new(PluginItemCommandStatus.InvalidItem); + } + if (!runtime.InventoryOwner.Transactions.CanBeginRequest) + return new(PluginItemCommandStatus.Busy); + return salvage(toolObjectId, itemObjectIds) + ? new(PluginItemCommandStatus.Started) + : new(PluginItemCommandStatus.Refused); + } + + public PluginItemCommandResult Sell(uint objectId, uint amount = 0u) + { + Func? sell; + GameRuntime? runtime; + lock (_gate) + { + sell = _sellItem; + runtime = _runtime; + } + if (runtime is null || sell is null || !IsAvailable) + return new(PluginItemCommandStatus.Unavailable); + uint vendorId = runtime.InventoryOwner.Vendor.VendorId; + if (vendorId == 0u) + return new(PluginItemCommandStatus.InvalidTarget, "No vendor is open."); + + ClientObjectTable objects = runtime.InventoryOwner.Objects; + uint playerId = runtime.PlayerIdentity.ServerGuid; + if (!TryGetOwned(objects, playerId, objectId, out ClientObject? item)) + return new(PluginItemCommandStatus.InvalidItem); + if (!ValidAmount(item!, amount)) + return new(PluginItemCommandStatus.Refused, "Invalid stack quantity."); + int quantity = checked((int)(amount == 0u + ? (uint)Math.Max(1, item!.StackSize) + : amount)); + int perUnitValue = VendorPricing.PerUnitValue(item!.Value, item.StackSize); + VendorShopProfile profile = runtime.InventoryOwner.Vendor.Profile; + VendorSellRejection rejection = VendorSellAcceptability.Evaluate( + ownedByPlayer: true, + containedItemCount: objects.GetContents(objectId).Count, + itemTypeMask: (uint)item.Type, + perUnitValue, + profile.MerchandiseItemTypes, + profile.MerchandiseMinValue, + profile.MerchandiseMaxValue, + item.PublicWeenieBitfield ?? 0u); + if (rejection != VendorSellRejection.None) + { + return new PluginItemCommandResult( + PluginItemCommandStatus.Refused, + VendorSellAcceptability.MessageFor(rejection)); + } + if (!runtime.InventoryOwner.Transactions.CanBeginRequest) + return new(PluginItemCommandStatus.Busy); + return sell(vendorId, objectId, quantity) + ? new(PluginItemCommandStatus.Started) + : new(PluginItemCommandStatus.Refused); + } + + private PluginItemCommandResult DispatchItem( + uint objectId, + uint targetObjectId) + { + Func? use; + Func? apply; + GameRuntime? runtime; + lock (_gate) + { + use = _useItem; + apply = _applyItem; + runtime = _runtime; + } + if (runtime is null || use is null || apply is null || !IsAvailable) + return new(PluginItemCommandStatus.Unavailable); + ClientObjectTable objects = runtime.InventoryOwner.Objects; + uint playerId = runtime.PlayerIdentity.ServerGuid; + if (objectId == 0u + || objects.Get(objectId) is not { } item + || !IsPlayerOwned(item, playerId, objects)) + { + return new(PluginItemCommandStatus.InvalidItem); + } + if (targetObjectId != 0u && objects.Get(targetObjectId) is null) + return new(PluginItemCommandStatus.InvalidTarget); + if (!runtime.InventoryOwner.Transactions.CanBeginRequest) + return new(PluginItemCommandStatus.Busy); + bool started = targetObjectId == 0u + ? use(objectId) + : apply(objectId, targetObjectId); + return started + ? new(PluginItemCommandStatus.Started) + : new(PluginItemCommandStatus.Refused); + } + + private static bool IsPlayerOwned( + ClientObject item, + uint playerId, + ClientObjectTable objects) + { + if (item.WielderId == playerId || item.ContainerId == playerId) + return true; + uint parentId = item.ContainerId; + for (int depth = 0; parentId != 0u && depth < 4; depth++) + { + ClientObject? parent = objects.Get(parentId); + if (parent is null) + return false; + if (parent.WielderId == playerId || parent.ContainerId == playerId) + return true; + parentId = parent.ContainerId; + } + return false; + } + + private static bool TryGetOwned( + ClientObjectTable objects, + uint playerId, + uint objectId, + out ClientObject? item) + { + item = objectId == 0u ? null : objects.Get(objectId); + return item is not null && IsPlayerOwned(item, playerId, objects); + } + + private static bool ValidAmount(ClientObject item, uint amount) => + amount == 0u || amount <= (uint)Math.Max(1, item.StackSize); + + // ── ILootAutomation ────────────────────────────────────────────────── + bool ILootAutomation.IsAvailable + { + get + { + lock (_gate) + return !_disposed && _useItem is not null + && _pickupItem is not null + && _identifyItem is not null + && IsAvailable; + } + } + + bool ILootAutomation.IsBusy + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime is not null + && !runtime.InventoryOwner.Transactions.CanBeginRequest; + } + } + + uint ILootAutomation.RequestedContainerId + { + get + { + lock (_gate) + return _runtime?.InventoryOwner.ExternalContainers + .RequestedContainerId ?? 0u; + } + } + + uint ILootAutomation.CurrentContainerId + { + get + { + lock (_gate) + return _runtime?.InventoryOwner.ExternalContainers + .CurrentContainerId ?? 0u; + } + } + + PluginItemUseCompletion ILootAutomation.LastItemUseCompletion => + ((IItemAutomation)this).LastCompletion; + + PluginInventoryCompletion ILootAutomation.LastInventoryCompletion + { + get + { + lock (_gate) + return _lastInventoryCompletion; + } + } + + PluginAppraisalState ILootAutomation.Appraisal + { + get + { + lock (_gate) + { + RuntimeInteractionTransactionState? transactions = + _runtime?.ActionOwner.Transactions; + return transactions is null + ? default + : new PluginAppraisalState( + transactions.Revision, + transactions.AwaitingAppraisalId, + transactions.CurrentAppraisalId); + } + } + } + + public IReadOnlyList CaptureCorpses( + float maximumDistance) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable + || float.IsNaN(maximumDistance) + || maximumDistance <= 0f) + { + return Array.Empty(); + } + + ExternalContainerState external = + runtime.InventoryOwner.ExternalContainers; + var result = new List(); + foreach (ClientObject candidate in runtime.InventoryOwner.Objects.Objects) + { + if (((PublicWeenieFlags)(candidate.PublicWeenieBitfield ?? 0u) + & PublicWeenieFlags.Corpse) == 0 + || candidate.ContainerId != 0u + || !RuntimeFriendlyTargetQuery.TryGetDistance( + runtime, + candidate.ObjectId, + out float distance) + || distance > maximumDistance) + { + continue; + } + + result.Add(new PluginLootContainer( + candidate.ObjectId, + candidate.WeenieClassId, + candidate.GetAppropriateName(), + distance, + external.HasCorpseBeenOpened(candidate.ObjectId), + external.RequestedContainerId == candidate.ObjectId, + external.CurrentContainerId == candidate.ObjectId) + { + LongDescription = candidate.Properties.GetString( + (uint)PropertyString.LongDesc), + IsGeneratedRare = candidate.Properties.GetBool( + (uint)PropertyBool.CorpseGeneratedRare), + IsIdentified = candidate.Properties.Strings.ContainsKey( + (uint)PropertyString.LongDesc), + }); + } + result.Sort(static (left, right) => + { + int distance = left.Distance.CompareTo(right.Distance); + return distance != 0 + ? distance + : left.ObjectId.CompareTo(right.ObjectId); + }); + return result.Count == 0 + ? Array.Empty() + : result.ToArray(); + } + + public IReadOnlyList CaptureCurrentContents() + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return Array.Empty(); + + uint root = runtime.InventoryOwner.ExternalContainers.CurrentContainerId; + if (root == 0u) + return Array.Empty(); + + ClientObjectTable objects = runtime.InventoryOwner.Objects; + var result = new List(); + var visited = new HashSet { root }; + CaptureContainerTree(runtime, objects, root, visited, result); + return result.Count == 0 + ? Array.Empty() + : result.ToArray(); + } + + bool ILootAutomation.TryCaptureProperties( + uint objectId, + out PluginItemProperties properties) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + { + properties = default; + return false; + } + + uint root = runtime.InventoryOwner.ExternalContainers.CurrentContainerId; + ClientObjectTable objects = runtime.InventoryOwner.Objects; + if (root == 0u + || !CaptureContainerIds(objects, root).Contains(objectId) + || objects.Get(objectId) is not { } item) + { + properties = default; + return false; + } + properties = CaptureProperties(item.Properties); + return true; + } + + public PluginItemCommandResult Open(uint containerObjectId) + { + GameRuntime? runtime; + Func? use; + lock (_gate) + { + runtime = _runtime; + use = _useItem; + } + if (runtime is null || use is null || !IsAvailable) + return new(PluginItemCommandStatus.Unavailable); + if (containerObjectId == 0u + || runtime.InventoryOwner.Objects.Get(containerObjectId) + is not { } container + || ((PublicWeenieFlags)(container.PublicWeenieBitfield ?? 0u) + & (PublicWeenieFlags.Corpse | PublicWeenieFlags.Openable)) == 0) + { + return new(PluginItemCommandStatus.InvalidTarget); + } + if (!runtime.InventoryOwner.Transactions.CanBeginRequest) + return new(PluginItemCommandStatus.Busy); + return use(containerObjectId) + ? new(PluginItemCommandStatus.Started) + : new(PluginItemCommandStatus.Refused); + } + + public PluginItemCommandResult Identify(uint objectId) + { + GameRuntime? runtime; + Func? identify; + lock (_gate) + { + runtime = _runtime; + identify = _identifyItem; + } + if (runtime is null || identify is null || !IsAvailable) + return new(PluginItemCommandStatus.Unavailable); + uint root = runtime.InventoryOwner.ExternalContainers.CurrentContainerId; + ClientObjectTable objects = runtime.InventoryOwner.Objects; + ClientObject? item = objectId == 0u ? null : objects.Get(objectId); + bool corpse = item is not null + && ((PublicWeenieFlags)(item.PublicWeenieBitfield ?? 0u) + & PublicWeenieFlags.Corpse) != 0; + bool currentContent = root != 0u + && CaptureContainerIds(objects, root).Contains(objectId); + if (item is null || (!corpse && !currentContent)) + { + return new(PluginItemCommandStatus.InvalidItem); + } + if (!runtime.InventoryOwner.Transactions.CanBeginRequest) + return new(PluginItemCommandStatus.Busy); + return identify(objectId) + ? new(PluginItemCommandStatus.Started) + : new(PluginItemCommandStatus.Refused); + } + + public PluginItemCommandResult Pickup(uint objectId, bool mainPack = false) + { + GameRuntime? runtime; + Func? pickup; + lock (_gate) + { + runtime = _runtime; + pickup = _pickupItem; + } + if (runtime is null || pickup is null || !IsAvailable) + return new(PluginItemCommandStatus.Unavailable); + uint root = runtime.InventoryOwner.ExternalContainers.CurrentContainerId; + ClientObjectTable objects = runtime.InventoryOwner.Objects; + if (objectId == 0u + || root == 0u + || objects.Get(objectId) is null + || !CaptureContainerIds(objects, root).Contains(objectId)) + { + return new(PluginItemCommandStatus.InvalidItem); + } + if (!runtime.InventoryOwner.Transactions.CanBeginRequest) + return new(PluginItemCommandStatus.Busy); + return pickup(objectId, mainPack) + ? new(PluginItemCommandStatus.Started) + : new(PluginItemCommandStatus.Refused); + } + + private static HashSet CaptureContainerIds( + ClientObjectTable objects, + uint root) + { + var result = new HashSet(); + var pending = new Stack(); + pending.Push(root); + while (pending.Count != 0) + { + uint containerId = pending.Pop(); + foreach (uint childId in objects.GetContents(containerId)) + { + if (!result.Add(childId)) + continue; + if (objects.Get(childId) is { } child + && (child.ItemsCapacity != 0 + || child.ContainersCapacity != 0 + || (child.Type & ItemType.Container) != 0)) + { + pending.Push(childId); + } + } + } + return result; + } + + private void CaptureContainerTree( + GameRuntime runtime, + ClientObjectTable objects, + uint containerId, + HashSet visited, + List result) + { + foreach (uint childId in objects.GetContents(containerId)) + { + if (!visited.Add(childId) + || objects.Get(childId) is not { } child) + { + continue; + } + result.Add(ProjectInventoryItem(runtime, child)); + if (child.ItemsCapacity != 0 + || child.ContainersCapacity != 0 + || (child.Type & ItemType.Container) != 0) + { + CaptureContainerTree(runtime, objects, childId, visited, result); + } + } + } + + private static PluginItemProperties CaptureProperties(PropertyBundle source) => + new( + new Dictionary(source.Ints), + new Dictionary(source.Int64s), + new Dictionary(source.Bools), + new Dictionary(source.Floats), + new Dictionary(source.Strings), + new Dictionary(source.DataIds), + new Dictionary(source.InstanceIds)); + + private PluginInventoryItem ProjectInventoryItem( + GameRuntime runtime, + ClientObject item) => + new( + item.ObjectId, + item.WeenieClassId, + item.GetAppropriateName(), + (uint)item.Type, + item.ContainerId, + item.WielderId, + (uint)item.ValidLocations, + (uint)item.CurrentlyEquippedLocation, + item.Useability ?? 0u, + item.TargetType ?? 0u, + item.PublicWeenieBitfield ?? 0u, + item.StackSize, + item.Structure, + item.MaxStructure, + item.SpellId + ?? (item.Properties.DataIds.TryGetValue( + (uint)PropertyDataId.Spell, + out uint itemSpell) ? itemSpell : 0u), + item.Properties.GetInt((uint)PropertyInt.PetClass), + item.Properties.GetInt((uint)PropertyInt.SummoningMastery), + item.Properties.DataIds.TryGetValue( + (uint)PropertyDataId.ProcSpell, + out uint procSpell) ? procSpell : 0u, + item.Properties.GetBool((uint)PropertyBool.ProcSpellSelfTargeted), + item.Properties.GetFloat((uint)PropertyFloat.ProcSpellRate), + item.Properties.GetInt((uint)PropertyInt.WeaponSkill), + item.Properties.GetInt((uint)PropertyInt.DamageType), + item.Properties.GetInt((uint)PropertyInt.Damage), + item.Properties.GetFloat((uint)PropertyFloat.DamageVariance), + item.Properties.GetInt((uint)PropertyInt.UseRequiresSkill), + item.Properties.GetInt((uint)PropertyInt.UseRequiresSkillLevel), + item.Properties.GetInt((uint)PropertyInt.UseRequiresSkillSpec)) + { + CombatUse = item.CombatUse ?? 0, + ItemSpellcraft = item.Properties.GetInt( + (uint)PropertyInt.ItemSpellcraft), + WieldRequirements = item.Properties.GetInt( + (uint)PropertyInt.WieldRequirements), + WieldSkillType = item.Properties.GetInt( + (uint)PropertyInt.WieldSkilltype), + WieldDifficulty = item.Properties.GetInt( + (uint)PropertyInt.WieldDifficulty), + AttackType = item.Properties.GetInt((uint)PropertyInt.AttackType), + WeaponType = item.Properties.GetInt((uint)PropertyInt.WeaponType), + BoosterVital = item.Properties.GetInt((uint)PropertyInt.BoosterEnum), + BoostValue = item.Properties.GetInt((uint)PropertyInt.BoostValue), + HealKitModifier = item.Properties.GetFloat( + (uint)PropertyFloat.HealkitMod), + AppraisedSpellIds = item.AppraisedSpellIds.Count == 0 + ? Array.Empty() + : item.AppraisedSpellIds.ToArray(), + GearDamage = item.Properties.GetInt((uint)PropertyInt.GearDamage), + GearDamageResistance = item.Properties.GetInt( + (uint)PropertyInt.GearDamageResist), + GearCriticalChance = item.Properties.GetInt( + (uint)PropertyInt.GearCrit), + GearCriticalResistance = item.Properties.GetInt( + (uint)PropertyInt.GearCritResist), + GearCriticalDamage = item.Properties.GetInt( + (uint)PropertyInt.GearCritDamage), + GearCriticalDamageResistance = item.Properties.GetInt( + (uint)PropertyInt.GearCritDamageResist), + MaximumStackSize = item.StackSizeMax, + ContainerSlot = item.ContainerSlot, + ItemsCapacity = item.ItemsCapacity, + ContainersCapacity = item.ContainersCapacity, + Burden = item.Burden, + Value = item.Value, + ItemCurrentMana = item.Properties.GetInt((uint)PropertyInt.ItemCurMana), + ItemMaximumMana = item.Properties.GetInt((uint)PropertyInt.ItemMaxMana), + Workmanship = item.Workmanship, + MaterialType = item.MaterialType ?? 0u, + ObjectClass = ClassifyObject(item), + Palettes = ProjectPalettes(runtime, item.ObjectId), + }; + + private IReadOnlyList ProjectPalettes( + GameRuntime runtime, + uint objectId) + { + IChargenPaletteColorSource? colors; + lock (_gate) + colors = _paletteColors; + if (colors is null + || !runtime.EntityObjects.Entities.TryGetActive( + objectId, + out RuntimeEntityRecord record) + || record.Snapshot.SubPalettes.Count == 0) + { + return Array.Empty(); + } + + var result = new PluginPaletteInfo[record.Snapshot.SubPalettes.Count]; + for (int index = 0; index < result.Length; index++) + { + var palette = record.Snapshot.SubPalettes[index]; + int sampleIndex = (palette.Length * 16) + (palette.Offset * 32) + 8; + _ = colors.TryGetColor( + palette.SubPaletteId, + sampleIndex, + out var rgb); + result[index] = new PluginPaletteInfo( + palette.SubPaletteId, + palette.Offset, + palette.Length, + rgb.R, + rgb.G, + rgb.B); + } + return result; + } + + // ── IFellowshipAutomation ──────────────────────────────────────────── + public bool IsInFellowship + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.Fellowship.Snapshot.IsInFellowship == true; + } + } + + public string FellowshipName + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.Fellowship.Snapshot.Name ?? string.Empty; + } + } + + string IFellowshipAutomation.Name => FellowshipName; + + public uint LeaderObjectId + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.Fellowship.Snapshot.LeaderGuid ?? 0u; + } + } + + public bool IsOpen + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.Fellowship.Snapshot.IsOpen == true; + } + } + + public bool IsLocked + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.Fellowship.Snapshot.Locked == true; + } + } + + public int MemberCount + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + return runtime?.Fellowship.Snapshot.MemberCount ?? 0; + } + } + + public IReadOnlyList CaptureMembers() + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable + || !runtime.Fellowship.Snapshot.IsInFellowship) + { + return Array.Empty(); + } + + uint self = runtime.PlayerIdentity.ServerGuid; + var result = new List(); + foreach (RuntimeFellowMemberSnapshot member + in runtime.Fellowship.GetMembers()) + { + if (member.Guid == self + || !RuntimeFriendlyTargetQuery.TryGetDistance( + runtime, + member.Guid, + out float distance)) + { + continue; + } + result.Add(new PluginFellowMember( + member.Guid, + member.Name, + member.CurrentHealth, + member.MaxHealth, + member.CurrentStamina, + member.MaxStamina, + member.CurrentMana, + member.MaxMana, + distance) + { + ShareLoot = member.ShareLoot, + }); + } + return result.Count == 0 + ? Array.Empty() + : result.ToArray(); + } + + public IReadOnlyList CaptureRoster() => + CaptureFellowshipMembers(includeSelf: true); + + public PluginFellowshipCommandResult Create( + string name, + bool shareExperience) => InvokeFellowship(commands => + commands.FellowshipCommands.Create( + commands.Generation, + name, + shareExperience)); + + public PluginFellowshipCommandResult Recruit(uint targetObjectId) => + InvokeFellowship(commands => commands.FellowshipCommands.Recruit( + commands.Generation, + targetObjectId)); + + public PluginFellowshipCommandResult Dismiss(uint targetObjectId) => + InvokeFellowship(commands => commands.FellowshipCommands.Dismiss( + commands.Generation, + targetObjectId)); + + public PluginFellowshipCommandResult Quit(bool disband) => + InvokeFellowship(commands => commands.FellowshipCommands.Quit( + commands.Generation, + disband)); + + public PluginFellowshipCommandResult AssignLeader(uint targetObjectId) => + InvokeFellowship(commands => commands.FellowshipCommands.AssignLeader( + commands.Generation, + targetObjectId)); + + public PluginFellowshipCommandResult SetOpen(bool isOpen) => + InvokeFellowship(commands => commands.FellowshipCommands.SetOpen( + commands.Generation, + isOpen)); + + private PluginFellowshipCommandResult InvokeFellowship( + Func invoke) + { + CurrentGameRuntimeAdapter? commands; + lock (_gate) + commands = _sessionCommands; + if (commands is null || !IsAvailable) + return new(PluginFellowshipCommandStatus.Unavailable); + RuntimeCommandResult result = invoke(commands); + return new(result.Status switch + { + RuntimeCommandStatus.Accepted => PluginFellowshipCommandStatus.Accepted, + RuntimeCommandStatus.Rejected => PluginFellowshipCommandStatus.Rejected, + _ => PluginFellowshipCommandStatus.Unavailable, + }); + } + + private IReadOnlyList CaptureFellowshipMembers(bool includeSelf) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable + || !runtime.Fellowship.Snapshot.IsInFellowship) + { + return Array.Empty(); + } + + uint self = runtime.PlayerIdentity.ServerGuid; + var result = new List(); + foreach (RuntimeFellowMemberSnapshot member in runtime.Fellowship.GetMembers()) + { + if (!includeSelf && member.Guid == self) + continue; + float distance = 0f; + if (member.Guid != self + && !RuntimeFriendlyTargetQuery.TryGetDistance( + runtime, + member.Guid, + out distance)) + { + continue; + } + result.Add(new PluginFellowMember( + member.Guid, + member.Name, + member.CurrentHealth, + member.MaxHealth, + member.CurrentStamina, + member.MaxStamina, + member.CurrentMana, + member.MaxMana, + distance) + { + ShareLoot = member.ShareLoot, + }); + } + return result.Count == 0 + ? Array.Empty() + : result.ToArray(); + } + + // ── IEnchantmentAutomation ────────────────────────────────────────── + public IReadOnlyList Capture(uint targetObjectId) + { + if (targetObjectId == 0u) + return Array.Empty(); + ObserveSuccessfulLocalCast(); + DateTimeOffset now = DateTimeOffset.UtcNow; + lock (_gate) + { + PruneTrackedEnchantments(now); + PluginTrackedEnchantment[] result = _trackedEnchantments + .Where(pair => pair.Key.Target == targetObjectId) + .Select(pair => new PluginTrackedEnchantment( + pair.Key.Target, + pair.Key.Spell, + pair.Value.Family, + pair.Value.Quality, + pair.Value.IsUntargeted, + Math.Max(0d, (pair.Value.ExpiresAt - now).TotalSeconds))) + .OrderBy(static entry => entry.Family) + .ThenBy(static entry => entry.SpellId) + .ToArray(); + return result.Length == 0 + ? Array.Empty() + : result; + } + } + + public bool ReportCast( + uint targetObjectId, + uint spellId, + double durationSeconds) + { + if (targetObjectId == 0u + || spellId == 0u + || !double.IsFinite(durationSeconds) + || durationSeconds <= 0d) + { + return false; + } + + lock (_gate) + { + if (_disposed + || _spellbook is null + || !_spellbook.TryGetMetadata(spellId, out SpellMetadata metadata)) + { + return false; + } + TrackEnchantment( + targetObjectId, + metadata, + durationSeconds, + DateTimeOffset.UtcNow); + return true; + } + } + + private void ObserveSuccessfulLocalCast() + { + lock (_gate) + { + RuntimeSpellCastCompletion completion = + _cast?.LastCompletion ?? default; + if (completion.Revision == 0 + || completion.Revision <= _trackedCastCompletionRevision) + { + return; + } + _trackedCastCompletionRevision = completion.Revision; + if (!completion.IsSuccess + || completion.TargetObjectId == 0u + || _spellbook is null + || !_spellbook.TryGetMetadata( + completion.SpellId, + out SpellMetadata metadata) + || metadata.Duration <= 0f) + { + return; + } + TrackEnchantment( + completion.TargetObjectId, + metadata, + metadata.Duration, + DateTimeOffset.UtcNow); + } + } + + private void TrackEnchantment( + uint targetObjectId, + SpellMetadata metadata, + double durationSeconds, + DateTimeOffset now) + { + var tracked = new TrackedEnchantment( + metadata.Family, + metadata.Difficulty, + metadata.IsUntargeted, + now.AddSeconds(durationSeconds)); + (uint Target, uint Spell) key = (targetObjectId, metadata.SpellId); + if (!_trackedEnchantments.TryGetValue(key, out TrackedEnchantment old) + || tracked.ExpiresAt > old.ExpiresAt) + { + _trackedEnchantments[key] = tracked; + } + } + + private void PruneTrackedEnchantments(DateTimeOffset now) + { + foreach ((uint Target, uint Spell) key in + _trackedEnchantments + .Where(pair => pair.Value.ExpiresAt <= now) + .Select(static pair => pair.Key) + .ToArray()) + { + _trackedEnchantments.Remove(key); + } + } + + // ── ICombatAutomation ──────────────────────────────────────────────── + public PluginCombatSnapshot Snapshot + { + get + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return default; + + RuntimeActionSnapshot action = runtime.ActionOwner.View.Snapshot; + RuntimeCombatAttackSnapshot attack = action.CombatAttack; + return new PluginCombatSnapshot( + action.SelectedObjectId, + Project(action.CombatMode), + Project(attack.RequestedHeight), + attack.DesiredPower, + attack.PowerBarLevel, + attack.BuildInProgress, + attack.RequestInProgress, + attack.ServerResponsePending, + attack.RepeatAttackInProgress) + { + CompletionRevision = attack.CompletionRevision, + CompletionSequence = attack.CompletionSequence, + CompletionWeenieError = attack.CompletionWeenieError, + }; + } + } + + public IReadOnlyList CaptureHostileTargets( + float maximumDistance) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return Array.Empty(); + + IReadOnlyList captured = + RuntimeHostileTargetQuery.Capture(runtime, maximumDistance); + if (captured.Count == 0) + return Array.Empty(); + + var projected = new PluginCombatTarget[captured.Count]; + Func speciesName; + lock (_gate) + speciesName = _speciesName; + for (int i = 0; i < captured.Count; i++) + { + RuntimeHostileTargetSnapshot target = captured[i]; + projected[i] = new PluginCombatTarget( + target.ObjectId, + target.Name, + target.WeenieClassId, + target.Distance, + target.RelativeAngleDegrees, + target.IsHealthKnown, + target.HealthFraction) + { + SpeciesId = target.SpeciesId, + SpeciesName = speciesName(target.SpeciesId), + MaximumHealth = target.MaximumHealth, + HasShield = target.HasShield, + Incarnation = target.Incarnation, + HealthRevision = target.HealthRevision, + SecondsSinceHealthUpdate = target.SecondsSinceHealthUpdate, + }; + } + return projected; + } + + public PluginCombatCommandResult EnterDefaultMode() + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return new(PluginCombatCommandStatus.Unavailable); + if (runtime.ActionOwner.Combat.CurrentMode != CombatMode.NonCombat) + return new(PluginCombatCommandStatus.AlreadyReady); + + RuntimeCombatModeRequestResult result = runtime.ActionOwner.CombatMode.Toggle(); + return result.Status switch + { + RuntimeCombatModeRequestStatus.Sent => new( + PluginCombatCommandStatus.ModeChangeSent), + RuntimeCombatModeRequestStatus.Rejected => new( + PluginCombatCommandStatus.Refused, result.Notice), + _ => new(PluginCombatCommandStatus.Unavailable, result.Notice), + }; + } + + public PluginCombatCommandResult EnterMode(PluginCombatMode mode) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return new(PluginCombatCommandStatus.Unavailable); + CombatMode requested = mode switch + { + PluginCombatMode.Peace => CombatMode.NonCombat, + PluginCombatMode.Melee => CombatMode.Melee, + PluginCombatMode.Missile => CombatMode.Missile, + PluginCombatMode.Magic => CombatMode.Magic, + _ => (CombatMode)(-1), + }; + if ((int)requested < 0) + return new(PluginCombatCommandStatus.Refused, "Invalid combat mode."); + if (runtime.ActionOwner.Combat.CurrentMode == requested) + return new(PluginCombatCommandStatus.AlreadyReady); + + RuntimeCombatModeRequestResult result = + runtime.ActionOwner.CombatMode.Request(requested); + return result.Status switch + { + RuntimeCombatModeRequestStatus.Sent => new( + PluginCombatCommandStatus.ModeChangeSent), + RuntimeCombatModeRequestStatus.Rejected => new( + PluginCombatCommandStatus.Refused, result.Notice), + _ => new(PluginCombatCommandStatus.Unavailable, result.Notice), + }; + } + + public PluginCombatCommandResult DismissGhostTarget(uint targetObjectId) + { + Func? dismiss; + lock (_gate) + dismiss = _dismissGhost; + if (dismiss is null || !IsAvailable) + return new(PluginCombatCommandStatus.Unavailable); + return dismiss(targetObjectId) + ? new(PluginCombatCommandStatus.Stopped) + : new(PluginCombatCommandStatus.InvalidTarget); + } + + public PluginCombatCommandResult BeginPhysicalAttack( + uint targetObjectId, + PluginAttackHeight height, + float power) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return new(PluginCombatCommandStatus.Unavailable); + if (!RuntimeHostileTargetQuery.IsHostile(runtime, targetObjectId)) + return new(PluginCombatCommandStatus.InvalidTarget); + if (!CombatInputPlanner.SupportsTargetedAttack( + runtime.ActionOwner.Combat.CurrentMode)) + { + return new(PluginCombatCommandStatus.WrongMode); + } + + RuntimeCombatAttackState attack = runtime.ActionOwner.CombatAttack; + if (attack.AttackRequestInProgress + || attack.AttackServerResponsePending + || attack.RepeatAttackInProgress) + { + return new(PluginCombatCommandStatus.Busy); + } + + runtime.ActionOwner.Selection.Select( + targetObjectId, + SelectionChangeSource.Plugin); + attack.SetDesiredPower(Math.Clamp(power, 0f, 1f)); + attack.PressAttack(Project(height)); + return attack.AttackRequestInProgress + ? new(PluginCombatCommandStatus.Started) + : new(PluginCombatCommandStatus.Refused); + } + + public PluginCombatCommandResult ReleasePhysicalAttack() + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable) + return new(PluginCombatCommandStatus.Unavailable); + + RuntimeCombatAttackState attack = runtime.ActionOwner.CombatAttack; + if (!attack.AttackRequestInProgress) + return new(PluginCombatCommandStatus.Refused); + attack.ReleaseAttack(); + return new(PluginCombatCommandStatus.Released); + } + + public PluginCombatCommandResult AbortPhysicalAttack() + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null) + return new(PluginCombatCommandStatus.Unavailable); + runtime.ActionOwner.CombatAttack.AbortAutomaticAttack(); + return new(PluginCombatCommandStatus.Stopped); + } + + private bool SelectExplicitTarget(uint targetObjectId) + { + GameRuntime? runtime; + lock (_gate) + runtime = _runtime; + if (runtime is null || !IsAvailable || targetObjectId == 0u + || !runtime.EntityObjects.Entities.TryGetActive(targetObjectId, out _)) + { + return false; + } + runtime.ActionOwner.Selection.Select( + targetObjectId, + SelectionChangeSource.Plugin); + return true; + } + + private static PluginCombatMode Project(CombatMode mode) => mode switch + { + CombatMode.NonCombat => PluginCombatMode.Peace, + CombatMode.Melee => PluginCombatMode.Melee, + CombatMode.Missile => PluginCombatMode.Missile, + CombatMode.Magic => PluginCombatMode.Magic, + _ => PluginCombatMode.Unknown, + }; + + private static PluginAttackHeight Project(AttackHeight height) => height switch + { + AttackHeight.High => PluginAttackHeight.High, + AttackHeight.Low => PluginAttackHeight.Low, + _ => PluginAttackHeight.Medium, + }; + + private static AttackHeight Project(PluginAttackHeight height) => height switch + { + PluginAttackHeight.High => AttackHeight.High, + PluginAttackHeight.Low => AttackHeight.Low, + _ => AttackHeight.Medium, + }; + + private readonly record struct TrackedEnchantment( + uint Family, + int Quality, + bool IsUntargeted, + DateTimeOffset ExpiresAt); + public void Dispose() { + if (_events is not null) + _events.Tick -= OnPeerTick; lock (_gate) { if (_disposed) return; _disposed = true; + _equip = null; + _equipmentBusy = null; + _useItem = null; + _applyItem = null; + _moveItem = null; + _mergeItems = null; + _dropItem = null; + _giveItem = null; + _pickupItem = null; + _identifyItem = null; + _salvageItems = null; + _sellItem = null; + _selectionAction = null; DetachLocked(); } _knownSelfBuffs = Array.Empty(); + _knownAttackSpells = Array.Empty(); + _knownCombatSpells = Array.Empty(); _enchantments = Array.Empty(); + _peers.Dispose(); } } diff --git a/src/AcDream.App/Plugins/AppPluginHost.cs b/src/AcDream.App/Plugins/AppPluginHost.cs index ad5fe525..06cf8f02 100644 --- a/src/AcDream.App/Plugins/AppPluginHost.cs +++ b/src/AcDream.App/Plugins/AppPluginHost.cs @@ -10,7 +10,10 @@ public sealed class AppPluginHost : IPluginHost IEvents events, ISelectionService selection, IUiRegistry ui, - IAutomationSurface automation) + IAutomationSurface automation, + IPluginStorage? storage = null, + IPluginCommandRegistry? commands = null, + IPluginLootClassifierRegistry? lootClassifiers = null) { Log = log; State = state; @@ -18,6 +21,10 @@ public sealed class AppPluginHost : IPluginHost Selection = selection; Ui = ui; Automation = automation; + Storage = storage ?? NoOpPluginStorage.Instance; + Commands = commands ?? NoOpPluginCommandRegistry.Instance; + LootClassifiers = lootClassifiers + ?? NoOpPluginLootClassifierRegistry.Instance; } public bool HasUi => true; @@ -27,4 +34,7 @@ public sealed class AppPluginHost : IPluginHost public ISelectionService Selection { get; } public IUiRegistry Ui { get; } public IAutomationSurface Automation { get; } + public IPluginStorage Storage { get; } + public IPluginCommandRegistry Commands { get; } + public IPluginLootClassifierRegistry LootClassifiers { get; } } diff --git a/src/AcDream.App/Plugins/BufferedUiRegistry.cs b/src/AcDream.App/Plugins/BufferedUiRegistry.cs index dc3c565e..3ec34095 100644 --- a/src/AcDream.App/Plugins/BufferedUiRegistry.cs +++ b/src/AcDream.App/Plugins/BufferedUiRegistry.cs @@ -11,18 +11,36 @@ namespace AcDream.App.Plugins; /// public sealed class BufferedUiRegistry : IScopedUiRegistry { - public readonly record struct Pending(string MarkupPath, object Binding) + public readonly record struct Pending( + PluginUiOwner Owner, + PluginPanelDescriptor Descriptor, + string MarkupPath, + object Binding) { internal long RegistrationId { get; init; } + internal string? MarkupContent { get; init; } + + /// Stable, manifest-scoped retained-window persistence key. + public string WindowName => + $"plugin:{Owner.Id}:{Descriptor.WindowId}"; } - private sealed class Registration(string markupPath, object binding) + private sealed class Registration( + PluginUiOwner owner, + PluginPanelDescriptor descriptor, + string markupPath, + object binding, + string? markupContent = null) { + internal PluginUiOwner Owner { get; } = owner; + internal PluginPanelDescriptor Descriptor { get; } = descriptor; internal string MarkupPath { get; } = markupPath; internal object Binding { get; } = binding; + internal string? MarkupContent { get; } = markupContent; internal bool Drained { get; set; } internal UiRoot? Root { get; set; } internal UiElement? Element { get; set; } + internal Action? WindowCleanup { get; set; } } private readonly object _gate = new(); @@ -32,15 +50,114 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry public void AddMarkupPanel(string markupPath, object binding) => _ = RegisterMarkupPanel(markupPath, binding); + public void AddPanel( + PluginPanelDescriptor descriptor, + string markupPath, + object binding) + => _ = RegisterPanel( + new PluginUiOwner("unscoped", descriptor.Title), + descriptor, + markupPath, + binding); + + public IDisposable RegisterPanel( + PluginPanelDescriptor descriptor, + string markupPath, + object binding) => RegisterPanel( + new PluginUiOwner("unscoped", descriptor.Title), + descriptor, + markupPath, + binding); + + public IDisposable RegisterPanelContent( + PluginPanelDescriptor descriptor, + string markupContent, + object binding) => RegisterPanelContent( + new PluginUiOwner("unscoped", descriptor.Title), + descriptor, + markupContent, + binding); + + public bool ViewExists(string viewName) => + ViewExists(new PluginUiOwner("unscoped", "Plugin"), viewName); + + public bool IsViewVisible(string viewName) => + IsViewVisible(new PluginUiOwner("unscoped", "Plugin"), viewName); + + public bool ControlExists(string viewName, string controlName) => + ControlExists( + new PluginUiOwner("unscoped", "Plugin"), viewName, controlName); + + public bool SetControlLabel( + string viewName, + string controlName, + string label) => SetControlLabel( + new PluginUiOwner("unscoped", "Plugin"), viewName, controlName, label); + + public bool SetControlVisible( + string viewName, + string controlName, + bool visible) => SetControlVisible( + new PluginUiOwner("unscoped", "Plugin"), viewName, controlName, visible); + public IDisposable RegisterMarkupPanel(string markupPath, object binding) + => RegisterPanel( + new PluginUiOwner("legacy", "Plugin"), + new PluginPanelDescriptor( + Path.GetFileNameWithoutExtension(markupPath), + Path.GetFileNameWithoutExtension(markupPath)), + markupPath, + binding); + + public IDisposable RegisterPanel( + PluginUiOwner owner, + PluginPanelDescriptor descriptor, + string markupPath, + object binding) { + ArgumentException.ThrowIfNullOrWhiteSpace(owner.Id); + ArgumentException.ThrowIfNullOrWhiteSpace(owner.DisplayName); + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.WindowId); + ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.Title); ArgumentException.ThrowIfNullOrWhiteSpace(markupPath); ArgumentNullException.ThrowIfNull(binding); long id; lock (_gate) { id = checked(++_nextRegistrationId); - _registrations.Add(id, new Registration(markupPath, binding)); + _registrations.Add( + id, + new Registration(owner, descriptor, markupPath, binding)); + } + return new RegistrationToken(this, id); + } + + public IDisposable RegisterPanelContent( + PluginUiOwner owner, + PluginPanelDescriptor descriptor, + string markupContent, + object binding) + { + ArgumentException.ThrowIfNullOrWhiteSpace(owner.Id); + ArgumentException.ThrowIfNullOrWhiteSpace(owner.DisplayName); + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.WindowId); + ArgumentException.ThrowIfNullOrWhiteSpace(descriptor.Title); + ArgumentException.ThrowIfNullOrWhiteSpace(markupContent); + ArgumentNullException.ThrowIfNull(binding); + long id; + lock (_gate) + { + id = checked(++_nextRegistrationId); + _registrations.Add( + id, + new Registration( + owner, + descriptor, + $"", + binding, + markupContent)); } return new RegistrationToken(this, id); } @@ -57,10 +174,13 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry continue; registration.Drained = true; pending.Add(new Pending( + registration.Owner, + registration.Descriptor, registration.MarkupPath, registration.Binding) { RegistrationId = id, + MarkupContent = registration.MarkupContent, }); } return pending; @@ -88,6 +208,27 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry root.RemoveChild(element); } + /// + /// Publishes the window-manager half of a mounted registration. Disposal + /// may race between retained-tree mount and window registration, so a late + /// publication cleans itself up immediately when ownership is already gone. + /// + internal void CompleteWindowMount(Pending pending, Action cleanup) + { + ArgumentNullException.ThrowIfNull(cleanup); + bool stillRegistered; + lock (_gate) + { + stillRegistered = _registrations.TryGetValue( + pending.RegistrationId, + out Registration? registration); + if (stillRegistered) + registration!.WindowCleanup = cleanup; + } + if (!stillRegistered) + cleanup(); + } + internal void FailMount(Pending pending) => Remove(pending.RegistrationId); internal int RegistrationCount @@ -99,18 +240,114 @@ public sealed class BufferedUiRegistry : IScopedUiRegistry } } + public bool ViewExists(PluginUiOwner owner, string viewName) + { + lock (_gate) + return FindRegistrationLocked(owner, viewName) is not null; + } + + public bool IsViewVisible(PluginUiOwner owner, string viewName) + { + UiElement? view; + lock (_gate) + view = FindRegistrationLocked(owner, viewName)?.Element; + return view?.Visible == true; + } + + public bool ControlExists( + PluginUiOwner owner, + string viewName, + string controlName) => + FindControl(owner, viewName, controlName) is not null; + + public bool SetControlLabel( + PluginUiOwner owner, + string viewName, + string controlName, + string label) + { + UiElement? control = FindControl(owner, viewName, controlName); + switch (control) + { + case UiSimpleButton button: + button.TextSource = null; + button.Text = label; + return true; + case UiMarkupToggle toggle: + toggle.TextSource = null; + toggle.Text = label; + return true; + case UiLabel text: + text.TextSource = null; + text.Text = label; + return true; + default: + return false; + } + } + + public bool SetControlVisible( + PluginUiOwner owner, + string viewName, + string controlName, + bool visible) + { + UiElement? control = FindControl(owner, viewName, controlName); + if (control is null) + return false; + control.VisibleSource = null; + control.Visible = visible; + return true; + } + + private UiElement? FindControl( + PluginUiOwner owner, + string viewName, + string controlName) + { + UiElement? view; + lock (_gate) + view = FindRegistrationLocked(owner, viewName)?.Element; + return view is null ? null : FindByName(view, controlName); + } + + private Registration? FindRegistrationLocked( + PluginUiOwner owner, + string viewName) => _registrations.Values.FirstOrDefault(registration => + registration.Owner == owner + && (registration.Descriptor.WindowId.Equals( + viewName, StringComparison.Ordinal) + || registration.Descriptor.Title.Equals( + viewName, StringComparison.Ordinal))); + + private static UiElement? FindByName(UiElement root, string name) + { + if (root.Name?.Equals(name, StringComparison.Ordinal) == true) + return root; + foreach (UiElement child in root.Children) + { + UiElement? found = FindByName(child, name); + if (found is not null) + return found; + } + return null; + } + private void Remove(long id) { UiRoot? root; UiElement? element; + Action? windowCleanup; lock (_gate) { if (!_registrations.Remove(id, out Registration? registration)) return; root = registration.Root; element = registration.Element; + windowCleanup = registration.WindowCleanup; } + windowCleanup?.Invoke(); if (root is not null && element is not null) root.RemoveChild(element); } diff --git a/src/AcDream.App/Plugins/FilePluginStorage.cs b/src/AcDream.App/Plugins/FilePluginStorage.cs new file mode 100644 index 00000000..85a0c60a --- /dev/null +++ b/src/AcDream.App/Plugins/FilePluginStorage.cs @@ -0,0 +1,86 @@ +using System.Text; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.Plugins; + +/// Crash-safe filesystem implementation behind scoped plugin keys. +internal sealed class FilePluginStorage : IPluginStorage +{ + private readonly string _root; + + internal FilePluginStorage(string root) + { + ArgumentException.ThrowIfNullOrWhiteSpace(root); + _root = Path.GetFullPath(root); + } + + public bool IsAvailable => true; + + public string? ReadText(string key) + { + string path = Resolve(key); + return File.Exists(path) + ? File.ReadAllText(path, Encoding.UTF8) + : null; + } + + public IReadOnlyList List(string prefix) + { + string directory = Resolve(prefix); + if (!Directory.Exists(directory)) + return Array.Empty(); + return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories) + .Select(path => Path.GetRelativePath(_root, path) + .Replace(Path.DirectorySeparatorChar, '/')) + .OrderBy(static key => key, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public void WriteText(string key, string content) + { + ArgumentNullException.ThrowIfNull(content); + string path = Resolve(key); + string directory = Path.GetDirectoryName(path)!; + Directory.CreateDirectory(directory); + string temporary = Path.Combine( + directory, + $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); + try + { + File.WriteAllText(temporary, content, new UTF8Encoding(false)); + File.Move(temporary, path, overwrite: true); + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + } + } + + public bool Delete(string key) + { + string path = Resolve(key); + if (!File.Exists(path)) + return false; + File.Delete(path); + return true; + } + + private string Resolve(string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + if (Path.IsPathRooted(key)) + throw new ArgumentException("Plugin storage keys must be relative.", nameof(key)); + string path = Path.GetFullPath(Path.Combine(_root, key)); + string relative = Path.GetRelativePath(_root, path); + if (Path.IsPathRooted(relative) + || relative.Equals("..", StringComparison.Ordinal) + || relative.StartsWith( + ".." + Path.DirectorySeparatorChar, + StringComparison.Ordinal)) + { + throw new ArgumentException("Plugin storage key escapes its root.", nameof(key)); + } + return path; + } +} diff --git a/src/AcDream.App/Plugins/LocalPluginPeerRegistry.cs b/src/AcDream.App/Plugins/LocalPluginPeerRegistry.cs new file mode 100644 index 00000000..17c7b37e --- /dev/null +++ b/src/AcDream.App/Plugins/LocalPluginPeerRegistry.cs @@ -0,0 +1,225 @@ +using System.Text.Json; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.Plugins; + +/// +/// Small cross-process peer roster for plugins. UtilityBelt used a local TCP +/// relay; acdream uses bounded heartbeat documents in the user's local app +/// data, which provides the same machine-local discovery without a privileged +/// daemon or a fixed port. The files carry data only—never commands. +/// +internal sealed class LocalPluginPeerRegistry : IDisposable +{ + internal static readonly TimeSpan StaleAfter = TimeSpan.FromSeconds(15); + private const long MaximumDocumentBytes = 64 * 1024; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly string _directory; + private readonly string _path; + private readonly TimeProvider _time; + private readonly Guid _instanceId; + private bool _disposed; + + public LocalPluginPeerRegistry( + string directory, + TimeProvider? timeProvider = null, + Guid? instanceId = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + _directory = Path.GetFullPath(directory); + _time = timeProvider ?? TimeProvider.System; + _instanceId = instanceId ?? Guid.NewGuid(); + _path = Path.Combine(_directory, $"peer-{_instanceId:N}.json"); + ClientId = BitConverter.ToUInt32(_instanceId.ToByteArray(), 0); + if (ClientId == 0u) + ClientId = 1u; + } + + public uint ClientId { get; private set; } + + public void Publish(in PluginNetworkClient client) + { + ObjectDisposedException.ThrowIf(_disposed, this); + Directory.CreateDirectory(_directory); + var document = PeerDocument.From( + client with { ClientId = ClientId }, + _instanceId, + _time.GetUtcNow().ToUnixTimeMilliseconds()); + string temporary = _path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + File.WriteAllText(temporary, JsonSerializer.Serialize(document, JsonOptions)); + File.Move(temporary, _path, overwrite: true); + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + } + } + + public IReadOnlyList CaptureRemoteClients() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!Directory.Exists(_directory)) + return Array.Empty(); + long newestAllowed = _time.GetUtcNow().Subtract(StaleAfter) + .ToUnixTimeMilliseconds(); + var result = new List(); + foreach (string file in Directory.EnumerateFiles( + _directory, + "peer-*.json", + SearchOption.TopDirectoryOnly)) + { + if (file.Equals(_path, StringComparison.OrdinalIgnoreCase)) + continue; + try + { + var info = new FileInfo(file); + if (info.Length is <= 0 or > MaximumDocumentBytes) + continue; + PeerDocument? document = JsonSerializer.Deserialize( + File.ReadAllText(file), + JsonOptions); + if (document is null + || document.InstanceId == _instanceId + || document.UpdatedUnixMs < newestAllowed + || document.ClientId == 0u + || document.PlayerId == 0u + || string.IsNullOrWhiteSpace(document.Name) + || document.Name.Length > 128 + || document.WorldName is null + || document.WorldName.Length > 128 + || document.Tags is null + || document.Tags.Length > 128 + || !double.IsFinite(document.EastWest) + || !double.IsFinite(document.NorthSouth) + || !double.IsFinite(document.Elevation) + || !float.IsFinite(document.Heading)) + { + continue; + } + result.Add(document.ToClient()); + } + catch (IOException) + { + // A peer can atomically replace or remove its own heartbeat + // between enumeration and read. It will reappear next scan. + } + catch (UnauthorizedAccessException) + { + } + catch (JsonException) + { + } + } + return result + .OrderBy(static client => client.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(static client => client.ClientId) + .ToArray(); + } + + public void Withdraw() + { + if (_disposed) + return; + try + { + if (File.Exists(_path)) + File.Delete(_path); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + + public void Dispose() + { + if (_disposed) + return; + Withdraw(); + _disposed = true; + } + + private sealed class PeerDocument + { + public Guid InstanceId { get; set; } + public long UpdatedUnixMs { get; set; } + public uint ClientId { get; set; } + public uint PlayerId { get; set; } + public string Name { get; set; } = string.Empty; + public string WorldName { get; set; } = string.Empty; + public string[] Tags { get; set; } = []; + public uint CellId { get; set; } + public double EastWest { get; set; } + public double NorthSouth { get; set; } + public double Elevation { get; set; } + public bool IsOutdoor { get; set; } + public float Heading { get; set; } + public uint CurrentHealth { get; set; } + public uint CurrentMana { get; set; } + public uint CurrentStamina { get; set; } + public uint MaxHealth { get; set; } + public uint MaxMana { get; set; } + public uint MaxStamina { get; set; } + + public static PeerDocument From( + in PluginNetworkClient client, + Guid instanceId, + long updatedUnixMs) => new() + { + InstanceId = instanceId, + UpdatedUnixMs = updatedUnixMs, + ClientId = client.ClientId, + PlayerId = client.PlayerId, + Name = client.Name, + WorldName = client.WorldName, + Tags = client.Tags + .Where(static tag => !string.IsNullOrWhiteSpace(tag)) + .Select(static tag => tag.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(128) + .ToArray(), + CellId = client.Position.CellId, + EastWest = client.Position.EastWest, + NorthSouth = client.Position.NorthSouth, + Elevation = client.Position.Elevation, + IsOutdoor = client.Position.IsOutdoor, + Heading = client.Heading, + CurrentHealth = client.CurrentHealth, + CurrentMana = client.CurrentMana, + CurrentStamina = client.CurrentStamina, + MaxHealth = client.MaxHealth, + MaxMana = client.MaxMana, + MaxStamina = client.MaxStamina, + }; + + public PluginNetworkClient ToClient() => new( + ClientId, + PlayerId, + Name, + WorldName, + new PluginNavigationPosition( + CellId, + EastWest, + NorthSouth, + Elevation, + Heading, + IsOutdoor), + Tags, + CurrentHealth, + CurrentMana, + CurrentStamina, + MaxHealth, + MaxMana, + MaxStamina, + Heading); + } +} diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index 32c3fecf..b736280d 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -160,7 +160,13 @@ using IDisposable atmosphericPackRegistration = renderPackRegistry.Register( "spv"))); // Constructed here and handed to both sides: GameWindow binds it to the live // session's Runtime owners, the plugin host exposes it to plugins. -using var automation = new AcDream.App.Plugins.AppAutomationSurface(); +using var automation = new AcDream.App.Plugins.AppAutomationSurface( + worldEvents, + new AcDream.App.Plugins.LocalPluginPeerRegistry(Path.Combine( + applicationPaths.DataDirectory, + "plugin-peers")), + runtimeOptions.PluginTags); +var lootClassifiers = new AcDream.Core.Plugins.PluginLootClassifierRegistry(); using var window = new GameWindow( runtimeOptions, worldGameState, @@ -175,7 +181,11 @@ var host = new AppPluginHost( worldEvents, window.Selection, uiRegistry, - automation); + automation, + new FilePluginStorage( + Path.Combine(applicationPaths.ConfigDirectory, "plugins")), + automation.PluginCommands, + lootClassifiers); GraphicalPluginSession pluginSession = GraphicalPluginSession.Create( applicationPaths, runtimeOptions.Plugins, diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b454dd94..6fba8f40 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -715,6 +715,7 @@ public sealed class GameWindow : // reset across generations. Re-binding per session would be re-binding // the same two references. _automation?.Bind(_runtime, _runtime.CharacterOwner, _runtime.ActionOwner.SpellCast); + _automation?.BindProjectileCollision(_physicsEngine); _localPlayerIdentity = new AcDream.App.Input.LocalPlayerIdentityState( _runtime.PlayerIdentity); _updateFrameClock = new AcDream.App.Update.UpdateFrameClock( @@ -960,6 +961,10 @@ public sealed class GameWindow : // zero skills with no error to explain it. if (_automation is null) return; + _automation.BindSpeciesNameResolver( + AcDream.App.UI.Layout.CreatureDisplayNameResolver.Load(value).Resolve); + _automation.BindPaletteColorResolver( + new AcDream.Content.CharGen.ChargenAppearanceCatalog(value)); if (!value.TryGet(0x0E000004u, out var skillTable) || skillTable is null) { @@ -983,8 +988,11 @@ public sealed class GameWindow : "prepared asset source"); void IGameWindowContentEffectsAudioPublication.PublishMagicCatalog( - MagicCatalog value) => + MagicCatalog value) + { PublishCompositionOwner(ref _magicCatalog, value, "magic catalog"); + _automation?.BindMagicCatalog(value); + } void IGameWindowContentEffectsAudioPublication.PublishAnimationLoader( AcDream.Core.Physics.IAnimationLoader value) => @@ -1144,6 +1152,25 @@ public sealed class GameWindow : _combatAttackController = result.CombatAttack; _externalContainerLifecycle = result.ExternalContainerLifecycle; _itemInteractionController = result.ItemInteraction; + _automation?.BindEquipment( + (itemId, requestedLocation) => + result.ItemInteraction.TryWieldItem( + itemId, + (AcDream.Core.Items.EquipMask)requestedLocation), + () => result.ItemInteraction.IsAutoWieldBusy); + _automation?.BindItems( + result.ItemInteraction.TryUseItemForAutomation, + result.ItemInteraction.TryApplyItem, + result.ItemInteraction.TryMoveItemForAutomation, + result.ItemInteraction.TryMergeItemsForAutomation, + result.ItemInteraction.TryDropItemForAutomation, + result.ItemInteraction.TryGiveItemForAutomation, + result.ItemInteraction.PlaceWorldItemInBackpack, + result.ItemInteraction.TryAppraiseForAutomation, + result.ItemInteraction.TrySalvageItemsForAutomation, + (vendorId, itemId, amount) => result.ItemInteraction.TrySell( + vendorId, + [(amount, itemId)])); _interactionUiLateBindings = result.LateBindings; _magicRuntime = result.Magic; if (result.RetainedUi is { } retained) @@ -1219,6 +1246,17 @@ public sealed class GameWindow : _retailSelectionScene = result.SelectionScene; _worldSelectionQuery = result.SelectionQuery; _selectionInteractions = result.SelectionInteractions; + _automation?.BindSelectionActions(action => + result.SelectionInteractions.HandleInputAction(action switch + { + AcDream.Plugin.Abstractions.PluginSelectionAction.PreviousSelection => + InputAction.SelectionPreviousSelection, + AcDream.Plugin.Abstractions.PluginSelectionAction.PreviousPlayer => + InputAction.SelectionPreviousPlayer, + AcDream.Plugin.Abstractions.PluginSelectionAction.NextPlayer => + InputAction.SelectionNextPlayer, + _ => InputAction.None, + })); _retainedUiGameplayBinding = result.RetainedGameplay; _paperdollViewportRenderer = result.PaperdollRenderer; _paperdollFramePresenter = result.PaperdollPresenter; @@ -1272,6 +1310,7 @@ public sealed class GameWindow : _worldReveal = result.WorldReveal; _spawnClaimHydration = result.SpawnClaimHydration; _liveEntityHydration = result.Hydration; + _automation?.BindGhostDeletion(result.Deletion.DeleteClientGhost); _liveEntityNetworkUpdates = result.NetworkUpdates; _liveEntityLiveness = result.Liveness; _liveEntitySessionEvents = result.SessionEvents; @@ -1282,6 +1321,7 @@ public sealed class GameWindow : _playerModeAutoEntry = result.PlayerModeAutoEntry; _localPlayerTeleport = result.LocalTeleport; _liveSessionHost = result.SessionHost; + _automation?.BindSessionCommands(result.GameRuntime); _gameplayInputActions = result.GameplayActions; _sessionPlayerBindings = result.RuntimeBindings; } @@ -1529,7 +1569,8 @@ public sealed class GameWindow : () => WorldTime.CurrentCalendar, settingsDevTools.RenderPacks, _renderPackDiagnostics.CaptureDiagnostics, - _applicationPaths.ScreenshotsDirectory), + _applicationPaths.ScreenshotsDirectory, + _automation), _retailUiLease, this).Compose( platformResult, @@ -1653,6 +1694,9 @@ public sealed class GameWindow : _combatFeedback, _portalTunnelFallback, Console.WriteLine, + _automation is null + ? null + : _automation.TryHandlePluginCommand, _statusWriter), this).Compose( hostInputCamera, diff --git a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs index 76ed43e5..e12fd765 100644 --- a/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs +++ b/src/AcDream.App/Runtime/CurrentGameRuntimeAdapter.cs @@ -2,6 +2,7 @@ using AcDream.App.Interaction; using AcDream.App.Net; using AcDream.Core.CharGen; using AcDream.Runtime; +using AcDream.Runtime.Chat; using AcDream.Runtime.Session; using AcDream.Runtime.World; using AcDream.UI.Abstractions; @@ -21,6 +22,7 @@ internal sealed class CurrentGameRuntimeAdapter { private readonly GameRuntime _runtime; private readonly CurrentGameRuntimeCommandAdapter _commands; + private readonly ICommandBus _commandBus; private readonly CharacterSelectionProjection _characterSelection; private readonly CharacterCreationProjection _characterCreation; private readonly IDisposable _hostLease; @@ -39,6 +41,7 @@ internal sealed class CurrentGameRuntimeAdapter ArgumentNullException.ThrowIfNull(commands); ArgumentNullException.ThrowIfNull(selection); + _commandBus = commands; _hostLease = runtime.AcquireHostLease( "graphical game-runtime command adapter"); try @@ -137,6 +140,24 @@ internal sealed class CurrentGameRuntimeAdapter public IRuntimeAllegianceCommands AllegianceCommands => _commands; IRuntimeAllegianceCommands IGameRuntimeCommands.Allegiance => _commands; + /// + /// Automation entrance to the same parser used by the retail chat field. + /// Plugins see this only through the BCL-only IPluginChat contract. + /// + internal bool SubmitChatText(string text) + { + if (!IsActive || string.IsNullOrWhiteSpace(text)) + return false; + SubmitOutcome outcome = ChatCommandRouter.Submit( + text, + new RuntimeChatCommandFeedback(_runtime.CommunicationOwner), + _commandBus, + ChatChannelKind.Say); + return outcome is not (SubmitOutcome.Empty + or SubmitOutcome.UnknownCommand + or SubmitOutcome.Dropped); + } + public RuntimeStateCheckpoint CaptureCheckpoint() { RuntimeStateCheckpoint checkpoint = _runtime.CaptureCheckpoint(); diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 53fb6a53..41790ed5 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -122,6 +122,11 @@ public sealed record RuntimeOptions( public uint? PreparedAssetEffectiveRecipeVersion { get; init; } + /// Optional machine-local peer tags advertised to other plugin + /// instances. Parsed once here so the live automation surface never reads + /// process configuration directly. + public IReadOnlyList PluginTags { get; init; } = []; + /// /// Build options from the process environment. Used by /// Program.cs at startup. @@ -247,7 +252,10 @@ public sealed record RuntimeOptions( StatusFilePath: null, Plugins: null, LoginCommands: [], - LoginCommandDelayMs: 500); + LoginCommandDelayMs: 500) + { + PluginTags = ParsePluginTags(env("ACDREAM_PLUGIN_TAGS")), + }; } /// @@ -367,6 +375,15 @@ public sealed record RuntimeOptions( private static string? NullIfEmpty(string? s) => string.IsNullOrEmpty(s) ? null : s; + private static IReadOnlyList ParsePluginTags(string? value) => + (value ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries + | StringSplitOptions.TrimEntries) + .Where(static tag => tag.Length <= 128) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(128) + .ToArray(); + private static int? TryParseInt(string? s) => int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : null; diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 6e767cab..d27c6ecb 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -75,6 +75,7 @@ public sealed class ItemInteractionController : IDisposable // dispatched-vs-silent-no-op shape as _sendBuy — see TryBuyAll/TrySell. private readonly Func, uint, bool>? _sendBuyAll; private readonly Func, bool>? _sendSell; + private readonly Func, bool>? _sendSalvage; private readonly RuntimeInteractionTransactionState _runtimeTransactions; private readonly InventoryTransactionState _transactions; @@ -120,7 +121,8 @@ public sealed class ItemInteractionController : IDisposable Func, uint, bool>? sendBuyAll = null, Func, bool>? sendSell = null, Action? interfaceText = null, - Action? sendStackableMerge = null) + Action? sendStackableMerge = null, + Func, bool>? sendSalvage = null) { _objects = objects ?? throw new ArgumentNullException(nameof(objects)); _playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid)); @@ -155,6 +157,7 @@ public sealed class ItemInteractionController : IDisposable _sendBuy = sendBuy; _sendBuyAll = sendBuyAll; _sendSell = sendSell; + _sendSalvage = sendSalvage; _interactionState = interactionState ?? throw new ArgumentNullException(nameof(interactionState)); _runtimeTransactions = runtimeTransactions @@ -500,6 +503,197 @@ public sealed class ItemInteractionController : IDisposable }); } + /// + /// Plugin-facing form of retail's put/split-to-container attempts. It + /// borrows this controller's exact transaction gate and wire delegates; + /// plugins supply policy, never a second optimistic inventory model. + /// + public bool TryMoveItemForAutomation( + uint itemId, + uint containerId, + uint amount = 0u, + int placement = 0) + { + if (itemId == 0u + || containerId == 0u + || _sendPutItemInContainer is null + || _objects.Get(itemId) is not { } item + || !IsOwnedByPlayer(itemId) + || (containerId != _playerGuid() && !IsOwnedByPlayer(containerId))) + { + return false; + } + + uint fullStack = (uint)Math.Max(1, item.StackSize); + uint requested = amount == 0u ? fullStack : amount; + if (requested == 0u || requested > fullStack) + return false; + if (requested < fullStack) + { + return TrySplitToContainer( + itemId, + containerId, + (uint)Math.Max(0, placement), + requested); + } + + return TryDispatchInventoryRequest( + InventoryRequestKind.PutInContainer, + itemId, + () => + { + _sendPutItemInContainer(itemId, containerId, placement); + return true; + }); + } + + /// + /// Plugin-facing retail stack merge. The shared planner performs the same + /// WCID, maximum-size, staged-trade, and transfer-size checks as a drag. + /// + public bool TryMergeItemsForAutomation( + uint sourceItemId, + uint targetItemId, + uint amount = 0u) + { + if (_sendStackableMerge is null + || !IsOwnedByPlayer(sourceItemId) + || !IsOwnedByPlayer(targetItemId) + || _objects.Get(sourceItemId) is not { } source + || _objects.Get(targetItemId) is not { } target) + { + return false; + } + + int requested = amount > int.MaxValue ? int.MaxValue : (int)amount; + StackMergePlan? plan = StackMergePlanner.Plan( + ToStackMergeItem(source), + ToStackMergeItem(target), + CanMakeInventoryRequest, + requested); + if (plan is not { } merge) + return false; + + return TryDispatchInventoryRequest( + InventoryRequestKind.Merge, + sourceItemId, + () => + { + _sendStackableMerge( + merge.SourceObjectId, + merge.TargetObjectId, + merge.Amount); + MergeAttempted?.Invoke( + merge.SourceObjectId, + merge.TargetObjectId); + return true; + }); + } + + /// Plugin-facing retail full-stack drop or split-to-world. + public bool TryDropItemForAutomation(uint itemId, uint amount = 0u) + { + if (!IsOwnedByPlayer(itemId) || _objects.Get(itemId) is not { } item) + return false; + + uint fullStack = (uint)Math.Max(1, item.StackSize); + uint requested = amount == 0u ? fullStack : amount; + if (requested == 0u || requested > fullStack) + return false; + InventoryRequestKind kind = requested < fullStack + ? InventoryRequestKind.SplitToWorld + : InventoryRequestKind.DropToWorld; + return TryDispatchInventoryRequest( + kind, + itemId, + () => + { + if (requested < fullStack) + { + if (_sendSplitToWorld is null) + return false; + _sendSplitToWorld(itemId, requested); + } + else + { + if (_sendDrop is null) + return false; + _sendDrop(itemId); + } + return true; + }); + } + + /// Plugin-facing retail Give attempt with an exact stack amount. + public bool TryGiveItemForAutomation( + uint itemId, + uint targetId, + uint amount = 0u) + { + if (_sendGive is null + || targetId == 0u + || targetId == _playerGuid() + || _objects.Get(targetId) is null + || !IsOwnedByPlayer(itemId) + || _objects.Get(itemId) is not { } item) + { + return false; + } + + uint fullStack = (uint)Math.Max(1, item.StackSize); + uint requested = amount == 0u ? fullStack : amount; + if (requested == 0u || requested > fullStack) + return false; + return TryDispatchInventoryRequest( + InventoryRequestKind.Give, + itemId, + () => + { + _sendGive(targetId, itemId, requested); + return true; + }); + } + + /// + /// Plugin-facing form of gmSalvageUI::Salvage. Retail validates an owned + /// tinkering tool and a non-empty ordered list of suitable owned source + /// items, then sends 0x027D without entering the ordinary one-item move + /// transaction. The server owns the final material and option checks. + /// + public bool TrySalvageItemsForAutomation( + uint toolId, + IReadOnlyList itemIds) + { + if (_sendSalvage is null + || toolId == 0u + || itemIds is null + || itemIds.Count == 0 + || !CanMakeInventoryRequest + || !IsOwnedByPlayer(toolId) + || _objects.Get(toolId) is not { } tool + || (tool.Type & ItemType.TinkeringTool) == 0) + { + return false; + } + + var distinct = new HashSet(); + foreach (uint itemId in itemIds) + { + if (itemId == 0u + || itemId == toolId + || !distinct.Add(itemId) + || !IsOwnedByPlayer(itemId) + || _objects.Get(itemId) is not { } item + || item.MaterialType is null or 0u + || item.Structure >= 100 + || ((item.PublicWeenieBitfield ?? 0u) & 0xFF000000u) != 0u) + { + return false; + } + } + return _sendSalvage(toolId, itemIds); + } + /// /// Increments retail's shared ClientUISystem busy reference after a /// request issued by another retained controller has been sent. The @@ -655,6 +849,21 @@ public sealed class ItemInteractionController : IDisposable _runtimeTransactions.TryRequestAppraisal(objectId, _sendExamine); } + /// + /// Plugin/automation appraisal through the one retail appraisal owner. + /// It does not mutate selection or open/raise the examination window. + /// + public bool TryAppraiseForAutomation(uint objectId) + { + if (objectId == 0u + || _sendExamine is null + || _objects.Get(objectId) is null) + { + return false; + } + return _runtimeTransactions.TryRequestAppraisal(objectId, _sendExamine); + } + /// /// Accepts only the pending or current appraisal, matching /// gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0. @@ -750,6 +959,76 @@ public sealed class ItemInteractionController : IDisposable return ExecuteUseActions(decision.Actions); } + /// + /// Plugin/automation entry for an ordinary item request. Unlike interactive + /// activation, it never turns a use request into wielding, sorting, or a + /// modal target cursor, and returns true only when a wire Use was issued. + /// + public bool TryUseItemForAutomation(uint itemGuid) + { + if (itemGuid == 0u || _objects.Get(itemGuid) is not { } item) + return false; + if (ItemUseability.IsTargeted(item.Useability ?? ItemUseability.Undef)) + return false; + if (!ConsumeUseThrottle()) + return false; + if (!EnsureInventoryRequestReady()) + return false; + + var input = new ItemUsePolicyInput( + Snapshot(item), + _playerGuid(), + _groundObjectId(), + CanMakeInventoryRequest, + _activeVendorId(), + BypassClassification: true, + UseCurrentSelection: false, + SelectedTarget: null, + ConfirmVolatileRareUses: true, + InNonCombatMode: _inNonCombatMode()); + ItemUsePolicyDecision decision = ItemInteractionPolicy.DecideUse(input); + bool sends = decision.Actions.Any(static action => + action.Kind == ItemPolicyActionKind.SendUse); + return sends && ExecuteUseActions(decision.Actions); + } + + /// + /// Plugin/automation entry for a targeted item action. It follows the same + /// retail compatibility, throttle, busy-reference and UseDone ownership as + /// choosing a target through the interactive target cursor, without + /// installing a modal cursor state that automation cannot safely own. + /// + public bool TryApplyItem(uint itemGuid, uint targetGuid) + { + if (itemGuid == 0u || targetGuid == 0u) + return false; + if (_objects.Get(itemGuid) is not { } item + || _objects.Get(targetGuid) is not { } target) + { + return false; + } + if (!ConsumeUseThrottle()) + return true; + if (!EnsureInventoryRequestReady()) + return false; + + var input = new ItemUsePolicyInput( + Snapshot(item), + _playerGuid(), + _groundObjectId(), + CanMakeInventoryRequest, + _activeVendorId(), + BypassClassification: true, + UseCurrentSelection: true, + SelectedTarget: Snapshot(target), + ConfirmVolatileRareUses: true, + InNonCombatMode: _inNonCombatMode()); + ItemUsePolicyDecision decision = ItemInteractionPolicy.DecideUse(input); + bool sends = decision.Actions.Any(static action => + action.Kind == ItemPolicyActionKind.SendUseWithTarget); + return sends && ExecuteUseActions(decision.Actions); + } + /// /// Retail keyboard pickup entry point. CPlayerSystem::PlaceInBackpack /// publishes the waiting destination slot before issuing the move request, @@ -1095,6 +1374,24 @@ public sealed class ItemInteractionController : IDisposable return _autoWield.TryWield(item, targetMask); } + /// + /// Plugin/automation entry into the exact same AutoWield transaction used + /// by inventory activation and paperdoll drops. + /// + public bool TryWieldItem(uint itemGuid, EquipMask requestedMask = EquipMask.None) + { + if (itemGuid == 0u || _objects.Get(itemGuid) is not { } item) + return false; + if (!EnsureInventoryRequestReady()) + return false; + return requestedMask == EquipMask.None + ? _autoWield.TryWield(item) + : _autoWield.TryWield(item, requestedMask); + } + + public bool IsAutoWieldBusy => + _autoWield.IsBusy || !_transactions.CanBeginRequest; + /// User combat-mode input supersedes AutoWield's retained mode. public void NotifyExplicitCombatModeRequest() => _autoWield.NotifyExplicitCombatModeRequest(); diff --git a/src/AcDream.App/UI/Layout/ProjectileDebugOverlayController.cs b/src/AcDream.App/UI/Layout/ProjectileDebugOverlayController.cs new file mode 100644 index 00000000..3d271ddd --- /dev/null +++ b/src/AcDream.App/UI/Layout/ProjectileDebugOverlayController.cs @@ -0,0 +1,137 @@ +using System.Numerics; +using AcDream.Core.Selection; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.UI.Layout; + +/// +/// Retained-view projection of VTank's ShowCollisionDebug shapes. +/// The collision query remains in the canonical physics world; this owner +/// only projects its detached per-quantum samples into the already-open UI +/// phase, avoiding a nested Vulkan backbuffer pass. +/// +internal sealed class ProjectileDebugOverlayController +{ + private static readonly Vector4 ClearColor = new(0f, 1f, 0f, 0.95f); + private static readonly Vector4 BlockedColor = new(1f, 0f, 0f, 0.95f); + + private readonly UiPanel _root; + private readonly Func> _samples; + private readonly Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> + _camera; + private readonly List _markers = []; + + private ProjectileDebugOverlayController( + UiPanel root, + Func> samples, + Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> camera) + { + _root = root; + _samples = samples; + _camera = camera; + } + + internal static ProjectileDebugOverlayController Mount( + UiRoot host, + Func> samples, + Func<(Matrix4x4 View, Matrix4x4 Projection, Vector2 Viewport)> camera) + { + ArgumentNullException.ThrowIfNull(host); + ArgumentNullException.ThrowIfNull(samples); + ArgumentNullException.ThrowIfNull(camera); + var root = new UiPanel + { + Name = "PluginProjectileDebugOverlay", + BackgroundColor = Vector4.Zero, + BorderColor = Vector4.Zero, + ClickThrough = true, + Visible = false, + ZOrder = -9_999, + Anchors = AnchorEdges.None, + }; + host.AddChild(root); + return new ProjectileDebugOverlayController(root, samples, camera); + } + + internal void Tick() + { + IReadOnlyList samples = _samples(); + var camera = _camera(); + if (samples.Count == 0 + || camera.Viewport.X <= 0f + || camera.Viewport.Y <= 0f) + { + HideAll(); + return; + } + + EnsureMarkerCount(samples.Count); + _root.Left = 0f; + _root.Top = 0f; + _root.Width = camera.Viewport.X; + _root.Height = camera.Viewport.Y; + int visible = 0; + for (int index = 0; index < samples.Count; index++) + { + PluginProjectileDebugSample sample = samples[index]; + if (!ScreenProjection.TryProjectSphereToScreenRect( + sample.WorldPosition, + sample.Radius, + camera.View, + camera.Projection, + camera.Viewport, + out Vector2 minimum, + out Vector2 maximum, + out _, + minSidePixels: 4f) + || maximum.X < 0f + || maximum.Y < 0f + || minimum.X > camera.Viewport.X + || minimum.Y > camera.Viewport.Y) + { + continue; + } + + UiPanel marker = _markers[visible++]; + marker.Left = MathF.Max(0f, minimum.X); + marker.Top = MathF.Max(0f, minimum.Y); + marker.Width = MathF.Max( + 1f, + MathF.Min(camera.Viewport.X, maximum.X) - marker.Left); + marker.Height = MathF.Max( + 1f, + MathF.Min(camera.Viewport.Y, maximum.Y) - marker.Top); + marker.BorderColor = sample.IsClear ? ClearColor : BlockedColor; + marker.Visible = true; + } + for (int index = visible; index < _markers.Count; index++) + _markers[index].Visible = false; + _root.Visible = visible > 0; + } + + private void EnsureMarkerCount(int count) + { + while (_markers.Count < count) + { + var marker = new UiPanel + { + Name = $"PluginProjectileDebugMarker{_markers.Count}", + BackgroundColor = Vector4.Zero, + BorderColor = ClearColor, + BorderThickness = 1.5f, + ClickThrough = true, + Visible = false, + Anchors = AnchorEdges.None, + }; + _markers.Add(marker); + _root.AddChild(marker); + } + } + + private void HideAll() + { + _root.Visible = false; + for (int index = 0; index < _markers.Count; index++) + _markers[index].Visible = false; + } +} diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs index c45d3727..17c72a01 100644 --- a/src/AcDream.App/UI/MarkupDocument.cs +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -14,6 +14,12 @@ namespace AcDream.App.UI; /// public static class MarkupDocument { + // Retail's generic runtime-text tooltip skin. Plugin controls have no + // LayoutDesc of their own, so a tooltip= attribute explicitly opts them + // into the same popup that game-code SetTooltip call sites use. + private const uint RuntimeTooltipRootElementId = 0x10000397u; + private const uint RuntimeTooltipLayoutDid = 0x21000041u; + /// Raw XML markup for a single panel. /// Object whose public properties are bound to {PropName} attributes. /// Surface id → (GL handle, width, height) for chrome sprites. @@ -74,13 +80,47 @@ public static class MarkupDocument } foreach (var el in root.Elements()) + AddElement(panel, el, binding, resolve, datFont); + return panel; + } + + private static void AddElement( + UiElement parent, + XElement el, + object binding, + Func resolve, + UiDatFont? datFont) + { + switch (el.Name.LocalName) { - switch (el.Name.LocalName) - { - case "meter": + case "group": + var group = new UiPanel + { + Left = F(el, "x"), + Top = F(el, "y"), + Width = F(el, "w"), + Height = F(el, "h"), + BackgroundColor = el.Attribute("background") is null + ? Vector4.Zero + : Color((string?)el.Attribute("background")), + BorderColor = el.Attribute("border") is null + ? Vector4.Zero + : Color((string?)el.Attribute("border")), + BorderThickness = el.Attribute("border") is null ? 0f : 1f, + // Transparent layout groups do not claim empty space, while + // their interactive descendants remain hittable. + ClickThrough = true, + }; + ApplyCommon(group, el, binding); + parent.AddChild(group); + foreach (XElement child in el.Elements()) + AddElement(group, child, binding, resolve, datFont); + break; + + case "meter": var cur = BindUint((string?)el.Attribute("cur"), binding); var max = BindUint((string?)el.Attribute("max"), binding); - panel.AddChild(new UiMeter + var meter = new UiMeter { Left = F(el, "x"), Top = F(el, "y"), @@ -97,10 +137,12 @@ public static class MarkupDocument FrontLeft = Hex((string?)el.Attribute("frontleft")), FrontTile = Hex((string?)el.Attribute("fronttile")), FrontRight = Hex((string?)el.Attribute("frontright")), - }); + }; + ApplyCommon(meter, el, binding); + parent.AddChild(meter); break; - case "label": + case "label": // Text may be a literal or a {Binding}. Bound labels re-read // their property every frame through the Func, so a plugin // updates its status line by assigning a property rather @@ -114,10 +156,11 @@ public static class MarkupDocument }; if (el.Attribute("color") is not null) label.TextColor = Color((string?)el.Attribute("color")); - panel.AddChild(label); + ApplyCommon(label, el, binding); + parent.AddChild(label); break; - case "button": + case "button": // onclick binds to an Action property on the binding // object. Resolved once at build time: a button whose // handler silently failed to bind is a bug worth failing @@ -151,13 +194,243 @@ public static class MarkupDocument button.TextSource = BindString(caption, binding); if (el.Attribute("color") is not null) button.TextColor = Color((string?)el.Attribute("color")); + if (el.Attribute("background") is not null) + button.BackgroundColor = Color( + (string?)el.Attribute("background")); + if (el.Attribute("border") is not null) + button.BorderColor = Color( + (string?)el.Attribute("border")); + ApplyCommon(button, el, binding); if (onClick is not null) button.Click += onClick; - panel.AddChild(button); + parent.AddChild(button); break; - } + + case "tab": + string? tabClickName = (string?)el.Attribute("onclick"); + Action? tabClick = BindAction(tabClickName, binding); + if (tabClickName is not null && tabClick is null) + { + throw new FormatException( + $" did not resolve to an " + + $"Action property on {binding.GetType().Name}"); + } + + var tab = new UiMarkupTabButton + { + Left = F(el, "x"), + Top = F(el, "y"), + Width = F(el, "w"), + Height = F(el, "h"), + Text = (string?)el.Attribute("text") ?? string.Empty, + DatFont = datFont, + SelectedSource = BindRequiredBoolReader( + (string?)el.Attribute("selected"), + binding, + "tab selected"), + }; + ApplyCommon(tab, el, binding); + if (tabClick is not null) + tab.Click += tabClick; + parent.AddChild(tab); + break; + + case "toggle": + string? toggleClickName = (string?)el.Attribute("onclick"); + Action? toggleClick = BindAction(toggleClickName, binding); + if (toggleClickName is not null && toggleClick is null) + { + throw new FormatException( + $" did not resolve to an " + + $"Action property on {binding.GetType().Name}"); + } + + string? toggleCaption = (string?)el.Attribute("text"); + var toggle = new UiMarkupToggle + { + Left = F(el, "x"), + Top = F(el, "y"), + Width = F(el, "w"), + Height = F(el, "h"), + Text = toggleCaption ?? string.Empty, + TextSource = BindString(toggleCaption, binding), + CheckedSource = BindRequiredBoolReader( + (string?)el.Attribute("checked"), + binding, + "toggle checked"), + DatFont = datFont, + Toggle = toggleClick, + }; + if (el.Attribute("color") is not null) + toggle.TextColor = Color((string?)el.Attribute("color")); + ApplyCommon(toggle, el, binding); + parent.AddChild(toggle); + break; + + case "slider": + string? changeName = (string?)el.Attribute("onchange"); + Action? changed = BindFloatAction(changeName, binding); + if (changeName is not null && changed is null) + { + throw new FormatException( + $" did not resolve to an " + + $"Action property on {binding.GetType().Name}"); + } + + var slider = new UiScrollbar + { + Left = F(el, "x"), + Top = F(el, "y"), + Width = F(el, "w"), + Height = F(el, "h"), + Horizontal = true, + SpriteResolve = resolve, + ScalarPositionSource = BindFloat( + (string?)el.Attribute("value"), + binding), + ScalarChanged = changed, + }; + RetailScrollbarChrome.ApplyHorizontal(slider); + ApplyCommon(slider, el, binding); + parent.AddChild(slider); + break; + + case "field": + string? fieldChangeName = (string?)el.Attribute("onchange"); + Action? fieldChanged = BindStringAction( + fieldChangeName, + binding); + if (fieldChangeName is not null && fieldChanged is null) + { + throw new FormatException( + $" did not resolve to an " + + $"Action property on {binding.GetType().Name}"); + } + string? submitName = (string?)el.Attribute("onsubmit"); + Action? submitted = BindStringAction(submitName, binding); + if (submitName is not null && submitted is null) + { + throw new FormatException( + $" did not resolve to an " + + $"Action property on {binding.GetType().Name}"); + } + + var field = new UiField + { + Left = F(el, "x"), + Top = F(el, "y"), + Width = F(el, "w"), + Height = F(el, "h"), + DatFont = datFont, + BackgroundColor = el.Attribute("background") is null + ? new Vector4(0f, 0f, 0f, 0.9f) + : Color((string?)el.Attribute("background")), + TextColor = el.Attribute("color") is null + ? new Vector4(0.91f, 0.87f, 0.76f, 1f) + : Color((string?)el.Attribute("color")), + MaxCharacters = Math.Max(1, I(el, "maxlength", 128)), + ClearOnSubmit = B(el, "clearonsubmit", false), + RecordHistory = false, + OnTextChanged = fieldChanged, + OnSubmit = submitted, + }; + field.SetText(BindString((string?)el.Attribute("text"), binding)()); + ApplyCommon(field, el, binding); + parent.AddChild(field); + break; + + case "menu": + string? menuChangeName = (string?)el.Attribute("onchange"); + Action? menuChanged = BindStringAction( + menuChangeName, + binding); + if (menuChangeName is not null && menuChanged is null) + { + throw new FormatException( + $" did not resolve to an " + + $"Action property on {binding.GetType().Name}"); + } + Func> menuItems = BindStringList( + (string?)el.Attribute("items"), + binding, + "menu items"); + Func menuSelected = BindString( + (string?)el.Attribute("selected"), + binding); + var menu = new UiMenu + { + Left = F(el, "x"), + Top = F(el, "y"), + Width = F(el, "w"), + Height = F(el, "h"), + DatFont = datFont, + SpriteResolve = resolve, + RowsPerColumn = Math.Max(1, I(el, "rows", 7)), + RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)), + ColumnWidth = Math.Max(20f, F(el, "w")), + OpenUpward = B(el, "openupward", false), + TextIndent = 6f, + ButtonTextIndent = 6f, + NormalSprite = 0x06004D65u, + PressedSprite = 0x06004D66u, + PopupBgSprite = 0x0600124Cu, + ItemNormalSprite = 0x0600124Eu, + ItemHighlightSprite = 0x0600124Du, + ButtonLabelProvider = () => menuSelected() ?? string.Empty, + OnSelect = payload => + { + if (payload is string value) + menuChanged?.Invoke(value); + }, + }; + void RefreshMenu() + { + menu.Items = menuItems() + .Select(static value => new UiMenu.MenuItem(value, value)) + .ToArray(); + menu.Selected = menuSelected(); + } + RefreshMenu(); + menu.BeforeOpen = RefreshMenu; + ApplyCommon(menu, el, binding); + parent.AddChild(menu); + break; + + case "list": + string? listChangeName = (string?)el.Attribute("onchange"); + Action? listChanged = BindIntAction(listChangeName, binding); + if (listChangeName is not null && listChanged is null) + { + throw new FormatException( + $" did not resolve to an " + + $"Action property on {binding.GetType().Name}"); + } + var list = new UiMarkupList + { + Left = F(el, "x"), + Top = F(el, "y"), + Width = F(el, "w"), + Height = F(el, "h"), + RowHeight = Math.Max(12f, FOr(el, "rowheight", 18f)), + DatFont = datFont, + ItemsSource = BindStringList( + (string?)el.Attribute("items"), + binding, + "list items"), + ItemColorsSource = BindUintList( + (string?)el.Attribute("colors"), + binding, + "list colors"), + SelectedIndexSource = BindRequiredIntReader( + (string?)el.Attribute("selected"), + binding, + "list selected"), + SelectionChanged = listChanged, + }; + ApplyCommon(list, el, binding); + parent.AddChild(list); + break; } - return panel; } /// @@ -197,13 +470,197 @@ public static class MarkupDocument return () => (property.GetValue(binding) as Action)?.Invoke(); } + private static Action? BindFloatAction( + string? attribute, + object binding) + { + if (attribute is null || !IsBinding(attribute)) + return null; + + string name = attribute[1..^1]; + PropertyInfo? property = binding.GetType().GetProperty(name); + if (property is null + || !typeof(Action).IsAssignableFrom(property.PropertyType)) + return null; + return value => (property.GetValue(binding) as Action)?.Invoke(value); + } + + private static Action? BindStringAction( + string? attribute, + object binding) + { + if (attribute is null || !IsBinding(attribute)) + return null; + + string name = attribute[1..^1]; + PropertyInfo? property = binding.GetType().GetProperty(name); + if (property is null + || !typeof(Action).IsAssignableFrom(property.PropertyType)) + { + return null; + } + return value => (property.GetValue(binding) as Action)?.Invoke(value); + } + + private static Action? BindIntAction(string? attribute, object binding) + { + if (attribute is null || !IsBinding(attribute)) + return null; + PropertyInfo? property = binding.GetType().GetProperty(attribute[1..^1]); + if (property is null + || !typeof(Action).IsAssignableFrom(property.PropertyType)) + { + return null; + } + return value => (property.GetValue(binding) as Action)?.Invoke(value); + } + + private static Func> BindStringList( + string? expression, + object binding, + string context) + { + if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression)) + throw new FormatException($"{context} must be a string-list binding"); + PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]); + if (property is null + || !typeof(IEnumerable).IsAssignableFrom(property.PropertyType)) + { + throw new FormatException( + $"{expression} did not resolve to an IEnumerable property on " + + binding.GetType().Name); + } + return () => property.GetValue(binding) is IEnumerable values + ? values.ToArray() + : Array.Empty(); + } + + private static Func> BindUintList( + string? expression, + object binding, + string context) + { + if (string.IsNullOrWhiteSpace(expression)) + return static () => Array.Empty(); + if (!IsBinding(expression)) + throw new FormatException($"{context} must be a uint-list binding"); + PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]); + if (property is null + || !typeof(IEnumerable).IsAssignableFrom(property.PropertyType)) + { + throw new FormatException( + $"{expression} did not resolve to an IEnumerable property on " + + binding.GetType().Name); + } + return () => property.GetValue(binding) is IEnumerable values + ? values.ToArray() + : Array.Empty(); + } + private static bool IsBinding(string value) => value.Length > 2 && value[0] == '{' && value[^1] == '}'; + private static void ApplyCommon( + UiElement element, + XElement source, + object binding) + { + element.Name = (string?)source.Attribute("name") + ?? (string?)source.Attribute("id"); + BindBool((string?)source.Attribute("visible"), binding, + value => element.Visible = value, + sourceReader => element.VisibleSource = sourceReader); + BindBool((string?)source.Attribute("enabled"), binding, + value => element.Enabled = value, + sourceReader => element.EnabledSource = sourceReader); + + string? tooltip = (string?)source.Attribute("tooltip"); + if (!string.IsNullOrWhiteSpace(tooltip)) + { + element.RuntimeTooltipTextSource = BindString(tooltip, binding); + element.AuthoredTooltipRootElementId = RuntimeTooltipRootElementId; + element.AuthoredTooltipLayoutDid = RuntimeTooltipLayoutDid; + element.AuthoredTooltipEnabled = true; + } + } + + private static void BindBool( + string? expression, + object binding, + Action setLiteral, + Action> setSource) + { + if (string.IsNullOrWhiteSpace(expression)) + return; + if (!IsBinding(expression)) + { + if (bool.TryParse(expression, out bool literal)) + setLiteral(literal); + return; + } + + PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]); + if (property is null || property.PropertyType != typeof(bool)) + { + throw new FormatException( + $"{expression} did not resolve to a bool property on " + + binding.GetType().Name); + } + setSource(() => property.GetValue(binding) is true); + } + + private static Func BindRequiredBoolReader( + string? expression, + object binding, + string context) + { + if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression)) + throw new FormatException($"{context} must be a bool binding"); + + PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]); + if (property is null || property.PropertyType != typeof(bool)) + { + throw new FormatException( + $"{expression} did not resolve to a bool property on " + + binding.GetType().Name); + } + return () => property.GetValue(binding) is true; + } + + private static Func BindRequiredIntReader( + string? expression, + object binding, + string context) + { + if (string.IsNullOrWhiteSpace(expression) || !IsBinding(expression)) + throw new FormatException($"{context} must be an int binding"); + PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]); + if (property is null || property.PropertyType != typeof(int)) + { + throw new FormatException( + $"{expression} did not resolve to an int property on " + + binding.GetType().Name); + } + return () => property.GetValue(binding) is int value ? value : -1; + } + private static float F(XElement e, string attr) => float.TryParse((string?)e.Attribute(attr), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 0f; + private static float FOr(XElement e, string attr, float fallback) + => float.TryParse((string?)e.Attribute(attr), NumberStyles.Float, + CultureInfo.InvariantCulture, out float value) ? value : fallback; + + private static int I(XElement e, string attr, int fallback) + => int.TryParse((string?)e.Attribute(attr), NumberStyles.Integer, + CultureInfo.InvariantCulture, out int value) ? value : fallback; + + private static bool B(XElement e, string attr, bool fallback) + => bool.TryParse((string?)e.Attribute(attr), out bool value) + ? value + : fallback; + /// /// Parses #AARRGGBB → RGBA (alpha first, matching /// controls.ini convention). Falls back to opaque white on bad input. diff --git a/src/AcDream.App/UI/PluginSidePanel.cs b/src/AcDream.App/UI/PluginSidePanel.cs new file mode 100644 index 00000000..364839a2 --- /dev/null +++ b/src/AcDream.App/UI/PluginSidePanel.cs @@ -0,0 +1,355 @@ +using System.Numerics; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.UI; + +/// +/// Host-owned shelf for running gameplay plugins. A shelf button changes only +/// presentation visibility; it never touches plugin enable/session lifetime. +/// +public sealed class PluginSidePanel : UiPanel, IDisposable +{ + private const float OuterPadding = 4f; + private const float ButtonExtent = 28f; + private const float ButtonGap = 4f; + private const float DefaultTop = 116f; + + private readonly RetailWindowManager _windows; + private readonly Func _resolve; + private readonly UiDatFont? _font; + private readonly Dictionary _entries = []; + private bool _disposed; + private float _lastLayoutHeight = -1f; + + public PluginSidePanel( + RetailWindowManager windows, + Func resolve, + UiDatFont? font) + { + _windows = windows ?? throw new ArgumentNullException(nameof(windows)); + _resolve = resolve ?? throw new ArgumentNullException(nameof(resolve)); + _font = font; + + Width = ButtonExtent + OuterPadding * 2f; + Height = OuterPadding * 2f; + Top = DefaultTop; + Anchors = AnchorEdges.None; + Draggable = false; + Resizable = false; + BackgroundColor = new Vector4(0f, 0f, 0f, 0.88f); + BorderColor = new Vector4(0.62f, 0.48f, 0.16f, 1f); + BorderThickness = 1f; + Visible = false; + + _windows.WindowUnregistered += OnWindowUnregistered; + } + + /// Number of live plugin-window entries, exposed for gates. + public int EntryCount => _entries.Count; + + /// + /// Adds one manifest-scoped plugin window and its minimize affordance. + /// Duplicate handles are idempotent. + /// + public void Add( + PluginUiOwner owner, + PluginPanelDescriptor descriptor, + RetailWindowHandle handle) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentException.ThrowIfNullOrWhiteSpace(owner.Id); + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(handle); + if (_entries.ContainsKey(handle)) + return; + + // Plugin windows are ordinary retained windows, but unlike imported + // retail windows they have no authored MoveTo override. Keep their + // chrome reachable at the minimum 800x600 canvas and after a display + // resize. An oversized window follows retail's top-left-priority rule: + // pin to zero rather than stranding the title/minimize controls. + handle.OuterFrame.ConstrainDragToParent = true; + handle.OuterFrame.ConstrainResizeToParent = true; + KeepWindowReachable(handle); + + var button = new PluginShelfButton( + descriptor, + owner.DisplayName, + handle, + _resolve, + _font) + { + Width = ButtonExtent, + Height = ButtonExtent, + }; + button.Click += () => + { + if (handle.IsVisible) + handle.Hide(); + else + handle.Show(); + }; + + var minimize = new PluginMinimizeButton(handle, _font) + { + Left = MathF.Max(8f, handle.OuterFrame.Width - 23f), + Top = 3f, + Width = 18f, + Height = 17f, + Anchors = AnchorEdges.Top | AnchorEdges.Right, + }; + handle.OuterFrame.AddChild(minimize); + + _entries.Add(handle, new ShelfEntry(button, minimize)); + AddChild(button); + Reflow(); + } + + protected override void OnTick(double deltaSeconds) + { + base.OnTick(deltaSeconds); + + // Screen-edge dock: root bounds become authoritative at draw time, so + // compute this from the live parent rather than capturing an anchor + // margin while the pre-first-frame root still measures 0x0. + if (Parent is { } parent) + { + float availableHeight = MathF.Max( + ButtonExtent + OuterPadding * 2f, + parent.Height - Top - OuterPadding); + if (MathF.Abs(availableHeight - _lastLayoutHeight) > 0.5f) + { + _lastLayoutHeight = availableHeight; + Reflow(availableHeight); + } + Left = MathF.Max(0f, parent.Width - Width - OuterPadding); + } + + foreach (RetailWindowHandle handle in _entries.Keys) + KeepWindowReachable(handle); + + // The shelf remains reachable even after ordinary windows are raised. + if (Parent is { } root) + { + int highest = 0; + foreach (UiElement sibling in root.Children) + { + if (!ReferenceEquals(sibling, this)) + highest = Math.Max(highest, sibling.ZOrder); + } + if (ZOrder <= highest) + ZOrder = highest == int.MaxValue ? highest : highest + 1; + } + } + + private void OnWindowUnregistered(RetailWindowHandle handle) + { + if (!_entries.Remove(handle, out ShelfEntry entry)) + return; + + RemoveChild(entry.Button); + if (ReferenceEquals(entry.Minimize.Parent, handle.OuterFrame)) + handle.OuterFrame.RemoveChild(entry.Minimize); + entry.Button.DisposeSubscriptions(); + Reflow(); + } + + private static void KeepWindowReachable(RetailWindowHandle handle) + { + if (handle.OuterFrame.Parent is not { } parent + || parent.Width <= 0f + || parent.Height <= 0f) + { + return; + } + + float left = Math.Clamp( + handle.Left, + 0f, + MathF.Max(0f, parent.Width - handle.Width)); + float top = Math.Clamp( + handle.Top, + 0f, + MathF.Max(0f, parent.Height - handle.Height)); + if (left != handle.Left || top != handle.Top) + handle.MoveTo(left, top); + } + + private void Reflow(float maximumHeight = float.PositiveInfinity) + { + int maximumRows = float.IsPositiveInfinity(maximumHeight) + ? Math.Max(1, _entries.Count) + : Math.Max( + 1, + (int)MathF.Floor( + (maximumHeight - OuterPadding * 2f + ButtonGap) + / (ButtonExtent + ButtonGap))); + int index = 0; + foreach (ShelfEntry entry in _entries.Values) + { + int column = index / maximumRows; + int row = index % maximumRows; + entry.Button.Left = OuterPadding + + column * (ButtonExtent + ButtonGap); + entry.Button.Top = OuterPadding + + row * (ButtonExtent + ButtonGap); + index++; + } + + int rows = Math.Min(index, maximumRows); + int columns = index == 0 ? 1 : (index + maximumRows - 1) / maximumRows; + Width = OuterPadding * 2f + + columns * ButtonExtent + + Math.Max(0, columns - 1) * ButtonGap; + Height = OuterPadding * 2f + + rows * ButtonExtent + + Math.Max(0, rows - 1) * ButtonGap; + Visible = index > 0; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _windows.WindowUnregistered -= OnWindowUnregistered; + + foreach ((RetailWindowHandle handle, ShelfEntry entry) in _entries) + { + entry.Button.DisposeSubscriptions(); + if (ReferenceEquals(entry.Minimize.Parent, handle.OuterFrame)) + handle.OuterFrame.RemoveChild(entry.Minimize); + } + _entries.Clear(); + Visible = false; + } + + private readonly record struct ShelfEntry( + PluginShelfButton Button, + PluginMinimizeButton Minimize); + + private sealed class PluginShelfButton : UiSimpleButton + { + private static readonly Vector4 HiddenBackground = + new(0.025f, 0.025f, 0.02f, 0.96f); + private static readonly Vector4 VisibleBackground = + new(0.09f, 0.19f, 0.055f, 0.96f); + private static readonly Vector4 HiddenBorder = + new(0.48f, 0.38f, 0.14f, 1f); + private static readonly Vector4 VisibleBorder = + new(0.76f, 0.64f, 0.25f, 1f); + + private readonly RetailWindowHandle _handle; + private readonly Func _resolve; + private readonly uint _iconSurfaceId; + private readonly string _tooltip; + + internal PluginShelfButton( + PluginPanelDescriptor descriptor, + string ownerDisplayName, + RetailWindowHandle handle, + Func resolve, + UiDatFont? font) + { + _handle = handle; + _resolve = resolve; + _iconSurfaceId = descriptor.IconSurfaceId; + _tooltip = string.Equals(descriptor.Title, ownerDisplayName, + StringComparison.Ordinal) + ? descriptor.Title + : $"{ownerDisplayName} — {descriptor.Title}"; + Text = _iconSurfaceId == 0 + ? Initials(descriptor.IconText, descriptor.Title) + : string.Empty; + DatFont = font; + Outline = true; + BorderThickness = 1f; + _handle.Shown += OnVisibilityChanged; + _handle.Hidden += OnVisibilityChanged; + RefreshPresentation(); + } + + public override string? GetTooltipText() => _tooltip; + + protected override void OnTick(double deltaSeconds) + { + base.OnTick(deltaSeconds); + RefreshPresentation(); + } + + protected override void OnDraw(UiRenderContext ctx) + { + base.OnDraw(ctx); + if (_iconSurfaceId == 0) + return; + + (uint texture, int width, int height) = _resolve(_iconSurfaceId); + if (texture == 0 || width <= 0 || height <= 0) + return; + float extent = MathF.Min(Width - 6f, Height - 6f); + ctx.DrawSprite( + texture, + (Width - extent) * 0.5f, + (Height - extent) * 0.5f, + extent, + extent, + 0f, + 0f, + 1f, + 1f, + Vector4.One); + } + + internal void DisposeSubscriptions() + { + _handle.Shown -= OnVisibilityChanged; + _handle.Hidden -= OnVisibilityChanged; + } + + private void OnVisibilityChanged(RetailWindowHandle _) => + RefreshPresentation(); + + private void RefreshPresentation() + { + BackgroundColor = _handle.IsVisible + ? VisibleBackground + : HiddenBackground; + BorderColor = _handle.IsVisible ? VisibleBorder : HiddenBorder; + } + + private static string Initials(string? requested, string title) + { + if (!string.IsNullOrWhiteSpace(requested)) + return requested.Trim()[..Math.Min(3, requested.Trim().Length)]; + + string[] words = title.Split( + ' ', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (words.Length == 0) + return "?"; + if (words.Length == 1) + return words[0][..Math.Min(2, words[0].Length)].ToUpperInvariant(); + return string.Concat(words.Take(2).Select(static word => + char.ToUpperInvariant(word[0]))); + } + } + + private sealed class PluginMinimizeButton : UiSimpleButton + { + private readonly RetailWindowHandle _handle; + + internal PluginMinimizeButton(RetailWindowHandle handle, UiDatFont? font) + { + _handle = handle; + Text = "–"; + DatFont = font; + Outline = true; + BackgroundColor = new Vector4(0.02f, 0.02f, 0.015f, 0.94f); + BorderColor = new Vector4(0.58f, 0.46f, 0.17f, 1f); + BorderThickness = 1f; + Click += () => _handle.Hide(); + } + + public override string? GetTooltipText() => "Minimize to plugin sidepanel"; + } +} diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 8ac69a8e..2683d01e 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -25,6 +25,7 @@ using AcDream.UI.Abstractions.Panels.Chat; using AcDream.UI.Abstractions.Panels.Settings; using AcDream.UI.Abstractions.Panels.Vitals; using AcDream.UI.Abstractions.Input; +using AcDream.Plugin.Abstractions; using DatReaderWriter; using Silk.NET.Input; @@ -531,7 +532,9 @@ public sealed record RetailUiRuntimeBindings( CharacterSelectionRuntimeBindings? CharacterSelection = null, // Campaign CC slice CC4: sibling of CharacterSelection above. CharacterCreationRuntimeBindings? CharacterCreation = null, - Action? CaptureScreenshot = null); + Action? CaptureScreenshot = null, + Func>? + ProjectileDebugSamples = null); /// /// Composition owner for the production retained gameplay UI. GameWindow supplies @@ -553,9 +556,11 @@ public sealed class RetailUiRuntime : IDisposable private UiShortcutDigitGraphics? _shortcutDigitGraphics; private ItemCooldownUiController? _itemCooldownController; private VividTargetIndicatorController? _vividTargetIndicator; + private ProjectileDebugOverlayController? _projectileDebugOverlay; private Layout.VitalsSideBySideController? _vitalsSideBySide; private CharacterManagementUiMountCoordinator? _characterManagementMount; private CharacterCreationUiMountCoordinator? _characterCreationMount; + private PluginSidePanel? _pluginSidePanel; private IDisposable? _characterSheetSubscription; private Layout.CharacterTitlesController? _characterTitlesController; private ResourceShutdownTransaction? _shutdown; @@ -602,6 +607,7 @@ public sealed class RetailUiRuntime : IDisposable RetailUiRuntimeBindings bindings = _bindings; MountFpsDisplay(); MountVividTargetIndicator(); + MountProjectileDebugOverlay(); MountVitals(); MountRadar(); MountChat(); @@ -935,6 +941,7 @@ public sealed class RetailUiRuntime : IDisposable Layout.UiMediaClock.Advance(deltaSeconds); FpsController?.Tick(); _vividTargetIndicator?.Tick(); + _projectileDebugOverlay?.Tick(); _vitalsSideBySide?.Tick(); SpellbookWindowController?.Tick(); AppraisalController?.Tick(deltaSeconds); @@ -1655,6 +1662,18 @@ public sealed class RetailUiRuntime : IDisposable : "[D.2b] vivid target indicator mounted from client-enum category 0x10000009."); } + private void MountProjectileDebugOverlay() + { + if (_bindings.ProjectileDebugSamples is not { } samples) + return; + _projectileDebugOverlay = ProjectileDebugOverlayController.Mount( + Host.Root, + samples, + _bindings.VividTarget.Camera); + Console.WriteLine( + "[PluginUI] projectile collision debug overlay mounted."); + } + private void MountVitals() { ImportedLayout? layout = Import(0x2100006Cu); @@ -4608,16 +4627,64 @@ public sealed class RetailUiRuntime : IDisposable { try { - string xml = File.ReadAllText(panel.MarkupPath); - UiElement element = MarkupDocument.Build( + string xml = panel.MarkupContent + ?? File.ReadAllText(panel.MarkupPath); + UiNineSlicePanel element = MarkupDocument.Build( xml, panel.Binding, _bindings.Assets.ResolveSprite, _bindings.Assets.Controls, _bindings.Assets.DefaultFont); + + if (Host.WindowManager.TryGet(panel.WindowName, out _)) + { + throw new InvalidOperationException( + $"Plugin window '{panel.WindowName}' is already registered. " + + "Window ids must be unique within one plugin."); + } + + // Markup's root visibility is an availability gate (for example, + // a world-only panel), while the descriptor/persisted state is the + // user's minimize choice. Keep those two axes independent so an + // availability transition never disables the running plugin or + // forgets that the user wanted its window open. + Func? availability = element.VisibleSource; + var visibility = new PluginWindowVisibilityController( + availability, + panel.Descriptor.StartVisible); + element.VisibleSource = visibility.ShouldBeVisible; + element.Visible = visibility.ShouldBeVisible(); + Host.Root.AddChild(element); + // Publish ownership immediately after the tree mutation. Any + // later registration/sidepanel failure then rolls the mounted + // subtree back through FailMount instead of leaking it. _bindings.Plugins.CompleteMount(panel, Host.Root, element); - Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}"); + RetailWindowHandle handle = Host.WindowManager.Register( + panel.WindowName, + element, + element, + visibility); + _bindings.Plugins.CompleteWindowMount( + panel, + () => Host.WindowManager.Unregister(panel.WindowName)); + + if (panel.Descriptor.ShowInSidePanel) + { + if (_pluginSidePanel is null) + { + _pluginSidePanel = new PluginSidePanel( + Host.WindowManager, + _bindings.Assets.ResolveSprite, + _bindings.Assets.DefaultFont); + Host.Root.AddChild(_pluginSidePanel); + } + _pluginSidePanel.Add(panel.Owner, panel.Descriptor, handle); + } + + Console.WriteLine( + $"[D.2b] plugin UI window loaded: {panel.WindowName} " + + $"({panel.MarkupPath})"); } catch (Exception ex) { @@ -5312,6 +5379,7 @@ public sealed class RetailUiRuntime : IDisposable { _characterSheetSubscription?.Dispose(); _characterTitlesController?.Dispose(); + _pluginSidePanel?.Dispose(); Host.WindowManager.WindowVisibilityChanged -= OnWindowVisibilityChanged; WindowLockPresentation.Dispose(); WindowOpacity.Dispose(); @@ -5339,6 +5407,36 @@ public sealed class RetailUiRuntime : IDisposable _disposed = _shutdown.IsComplete; } + /// + /// Separates plugin availability from the user's minimized/open choice. + /// Window-manager callbacks update only the latter; a false availability + /// predicate hides temporarily without forgetting the requested state. + /// + private sealed class PluginWindowVisibilityController( + Func? availability, + bool startVisible) : IRetainedPanelController + { + private bool _requestedVisible = startVisible; + + internal bool ShouldBeVisible() => + _requestedVisible && (availability?.Invoke() ?? true); + + public void OnShown() => _requestedVisible = true; + + public void OnHidden() + { + // Hidden because the markup's availability gate went false is + // temporary. Hidden while available is a real minimize/restore- + // persistence transition and changes the requested state. + if (availability?.Invoke() ?? true) + _requestedVisible = false; + } + + public void Dispose() + { + } + } + internal static ResourceShutdownTransaction CreateShutdownTransaction( Action disposeAutomation, Action disposePersistence, diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index 7d95d961..e0c3874f 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -55,7 +55,7 @@ public abstract class UiElement public uint DatElementId { get; internal set; } /// Human-readable name for debugging / FindByName. - public string? Name { get; init; } + public string? Name { get; set; } /// /// GF-13 (Campaign CC gate round 1, Batch A): mirrors @@ -274,6 +274,12 @@ public abstract class UiElement /// public Func? VisibleSource { get; set; } + /// + /// Optional live enabled reader. Declarative plugin controls use this to + /// expose unavailable/busy state without retaining presentation objects. + /// + public Func? EnabledSource { get; set; } + /// /// If true, will set focus here on click, /// routing WM_KEYDOWN / WM_CHAR to as @@ -642,7 +648,19 @@ public abstract class UiElement /// hover delay (0.25 seconds by default), then queries the widget's virtual "GetString" /// (vtable +0x88) to render the tooltip body. /// - public virtual string? GetTooltipText() => null; + /// + /// Runtime-created/plugin-markup widgets have no LayoutDesc property bag from + /// which to import P0x49. They use this live source instead; the markup host + /// still supplies retail's shared tooltip-popup locator, so presentation stays + /// inside the common retained tooltip pipeline rather than becoming plugin UI. + /// + public Func? RuntimeTooltipTextSource { get; set; } + + public virtual string? GetTooltipText() + { + string? text = RuntimeTooltipTextSource?.Invoke(); + return string.IsNullOrWhiteSpace(text) ? null : text; + } // ── Framework entry points (internal, called by UiRoot) ───────────── @@ -763,6 +781,8 @@ public abstract class UiElement if (VisibleSource is { } visibility) Visible = visibility(); if (!Visible) return; + if (EnabledSource is { } enabled) + Enabled = enabled(); OnTick(dt); for (int i = 0; i < _children.Count; i++) _children[i].TickSelfAndChildren(dt); diff --git a/src/AcDream.App/UI/UiField.cs b/src/AcDream.App/UI/UiField.cs index 00ac7e2c..57f502b3 100644 --- a/src/AcDream.App/UI/UiField.cs +++ b/src/AcDream.App/UI/UiField.cs @@ -110,6 +110,13 @@ public sealed class UiField : UiElement public Action? OnSubmit { get; set; } public Action? OnFocusGained { get; set; } public Action? OnFocusLost { get; set; } + /// + /// Live text mutation callback used by retained plugin markup. This is + /// deliberately separate from submit/focus-loss: editors need their + /// binding model to track typing so an adjacent button can consume the + /// current value without reaching into the widget tree. + /// + public Action? OnTextChanged { get; set; } private string _textValue = ""; @@ -127,8 +134,11 @@ public sealed class UiField : UiElement get => _textValue; set { + if (string.Equals(_textValue, value, StringComparison.Ordinal)) + return; _textValue = value; _textVersion++; + OnTextChanged?.Invoke(value); } } diff --git a/src/AcDream.App/UI/UiMarkupList.cs b/src/AcDream.App/UI/UiMarkupList.cs new file mode 100644 index 00000000..6e9d0125 --- /dev/null +++ b/src/AcDream.App/UI/UiMarkupList.cs @@ -0,0 +1,96 @@ +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Lightweight, data-bound string list for plugin markup. It owns only row +/// selection and scroll position; the plugin binding remains the sole owner of +/// rows and selected index. This deliberately avoids exposing App widget types +/// through the BCL plugin contract. +/// +public sealed class UiMarkupList : UiElement +{ + public Func> ItemsSource { get; set; } = + static () => Array.Empty(); + public Func> ItemColorsSource { get; set; } = + static () => Array.Empty(); + public Func SelectedIndexSource { get; set; } = static () => -1; + public Action? SelectionChanged { get; set; } + public UiDatFont? DatFont { get; set; } + public float RowHeight { get; set; } = 18f; + public float Padding { get; set; } = 3f; + public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.92f); + public Vector4 BorderColor { get; set; } = new(0.46f, 0.37f, 0.16f, 1f); + public Vector4 TextColor { get; set; } = new(0.91f, 0.87f, 0.76f, 1f); + public Vector4 SelectedColor { get; set; } = new(0.28f, 0.23f, 0.08f, 0.95f); + + private int _topRow; + + public override bool HandlesClick => true; + + protected override void OnDraw(UiRenderContext context) + { + IReadOnlyList items = ItemsSource(); + IReadOnlyList itemColors = ItemColorsSource(); + int visibleRows = VisibleRows; + int selected = SelectedIndexSource(); + if (selected >= 0 && selected < items.Count) + { + if (selected < _topRow) + _topRow = selected; + else if (selected >= _topRow + visibleRows) + _topRow = selected - visibleRows + 1; + } + ClampTop(items.Count, visibleRows); + + context.DrawFill(0f, 0f, Width, Height, BackgroundColor); + context.DrawRectOutline(0f, 0f, Width, Height, BorderColor, 1f); + int end = Math.Min(items.Count, _topRow + visibleRows); + for (int index = _topRow; index < end; index++) + { + float y = (index - _topRow) * RowHeight; + if (index == selected) + context.DrawFill(1f, y + 1f, Width - 2f, RowHeight - 1f, SelectedColor); + string text = items[index]; + Vector4 textColor = index < itemColors.Count + ? Rgb(itemColors[index]) + : TextColor; + float textY = y + MathF.Max(0f, + (RowHeight - (DatFont?.LineHeight ?? 14f)) * 0.5f); + if (DatFont is { } font) + context.DrawStringDat(font, text, Padding, textY, textColor, true); + else + context.DrawString(text, Padding, textY, textColor); + } + } + + public override bool OnEvent(in UiEvent e) + { + IReadOnlyList items = ItemsSource(); + if (e.Type == UiEventType.Scroll) + { + _topRow -= Math.Sign(e.Data0); + ClampTop(items.Count, VisibleRows); + return true; + } + if (e.Type != UiEventType.MouseDown || !Enabled) + return false; + int row = (int)MathF.Floor(e.Data2 / MathF.Max(1f, RowHeight)); + int index = _topRow + row; + if (row >= 0 && row < VisibleRows && index >= 0 && index < items.Count) + SelectionChanged?.Invoke(index); + return true; + } + + private int VisibleRows => Math.Max(1, (int)MathF.Floor( + Height / MathF.Max(1f, RowHeight))); + + private void ClampTop(int count, int visibleRows) => + _topRow = Math.Clamp(_topRow, 0, Math.Max(0, count - visibleRows)); + + private static Vector4 Rgb(uint value) => new( + ((value >> 16) & 0xFFu) / 255f, + ((value >> 8) & 0xFFu) / 255f, + (value & 0xFFu) / 255f, + 1f); +} diff --git a/src/AcDream.App/UI/UiMarkupTabButton.cs b/src/AcDream.App/UI/UiMarkupTabButton.cs new file mode 100644 index 00000000..c6ef28f8 --- /dev/null +++ b/src/AcDream.App/UI/UiMarkupTabButton.cs @@ -0,0 +1,47 @@ +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Compact KSML tab used by plugin windows. It deliberately uses the retained +/// input/font path and VTank's text-strip presentation rather than introducing +/// a plugin-owned renderer. +/// +public sealed class UiMarkupTabButton : UiSimpleButton +{ + private static readonly Vector4 ActiveText = + new(0.94f, 0.76f, 0.18f, 1f); + private static readonly Vector4 NormalText = + new(0.78f, 0.76f, 0.67f, 1f); + private static readonly Vector4 DisabledText = + new(0.34f, 0.33f, 0.29f, 1f); + private static readonly Vector4 Underline = + new(0.77f, 0.59f, 0.12f, 1f); + + public Func? SelectedSource { get; set; } + + public bool IsSelected => SelectedSource?.Invoke() ?? false; + + public UiMarkupTabButton() + { + BackgroundColor = Vector4.Zero; + BorderColor = Vector4.Zero; + BorderThickness = 0f; + Outline = true; + } + + protected override void OnTick(double deltaSeconds) + { + base.OnTick(deltaSeconds); + TextColor = !Enabled + ? DisabledText + : IsSelected ? ActiveText : NormalText; + } + + protected override void OnDraw(UiRenderContext ctx) + { + base.OnDraw(ctx); + if (IsSelected) + ctx.DrawFill(2f, Height - 2f, MathF.Max(0f, Width - 4f), 1f, Underline); + } +} diff --git a/src/AcDream.App/UI/UiMarkupToggle.cs b/src/AcDream.App/UI/UiMarkupToggle.cs new file mode 100644 index 00000000..bae133f0 --- /dev/null +++ b/src/AcDream.App/UI/UiMarkupToggle.cs @@ -0,0 +1,75 @@ +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// KSML boolean toggle with the compact lamp-and-caption presentation used by +/// VTank. State and action remain reflected BCL bindings owned by the plugin. +/// +public sealed class UiMarkupToggle : UiElement +{ + private static readonly Vector4 CheckedOuter = + new(0.36f, 0.58f, 0.12f, 1f); + private static readonly Vector4 CheckedInner = + new(0.52f, 1f, 0.08f, 1f); + private static readonly Vector4 UncheckedOuter = + new(0.26f, 0.22f, 0.13f, 1f); + private static readonly Vector4 UncheckedInner = + new(0.38f, 0.34f, 0.23f, 1f); + + public string Text { get; set; } = string.Empty; + public Func? TextSource { get; set; } + public Func? CheckedSource { get; set; } + public UiDatFont? DatFont { get; set; } + public Vector4 TextColor { get; set; } = + new(0.86f, 0.84f, 0.74f, 1f); + public Action? Toggle { get; set; } + + public bool IsChecked => CheckedSource?.Invoke() ?? false; + + public override bool HandlesClick => true; + + public override bool OnEvent(in UiEvent e) + { + if (e.Type != UiEventType.Click || !Enabled) + return false; + Toggle?.Invoke(); + return true; + } + + protected override void OnDraw(UiRenderContext ctx) + { + Vector4 outer = IsChecked ? CheckedOuter : UncheckedOuter; + Vector4 inner = IsChecked ? CheckedInner : UncheckedInner; + DrawLamp(ctx, 1f, MathF.Max(1f, (Height - 11f) * 0.5f), outer, inner); + + string caption = TextSource?.Invoke() ?? Text; + Vector4 color = Enabled + ? TextColor + : new Vector4(TextColor.X, TextColor.Y, TextColor.Z, 0.42f); + float y = DatFont is { } font + ? (Height - font.LineHeight) * 0.5f + : 1f; + if (DatFont is { } dat) + ctx.DrawStringDat(dat, caption, 17f, y, color, outline: true); + else + ctx.DrawString(caption, 17f, y, color); + } + + private static void DrawLamp( + UiRenderContext ctx, + float x, + float y, + Vector4 outer, + Vector4 inner) + { + // Five bands form the small circular indicator without introducing a + // plugin bitmap or a new renderer primitive. + ctx.DrawFill(x + 3f, y, 5f, 1f, outer); + ctx.DrawFill(x + 1f, y + 1f, 9f, 2f, outer); + ctx.DrawFill(x, y + 3f, 11f, 5f, outer); + ctx.DrawFill(x + 1f, y + 8f, 9f, 2f, outer); + ctx.DrawFill(x + 3f, y + 10f, 5f, 1f, outer); + ctx.DrawFill(x + 3f, y + 3f, 5f, 5f, inner); + } +} diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs index 56114730..f2b7237b 100644 --- a/src/AcDream.App/UI/UiMenu.cs +++ b/src/AcDream.App/UI/UiMenu.cs @@ -74,7 +74,9 @@ public sealed class UiMenu : UiElement string? live = TooltipTextProvider?.Invoke(); if (!string.IsNullOrWhiteSpace(live)) return live; - return string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText; + return string.IsNullOrWhiteSpace(TooltipText) + ? base.GetTooltipText() + : TooltipText; } public int RowsPerColumn { get; set; } = 7; // items per column (dat item template); diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs index d6569b3c..8f8fa702 100644 --- a/src/AcDream.App/UI/UiScrollbar.cs +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -37,6 +37,12 @@ public sealed class UiScrollbar : UiElement /// public float ScalarPosition { get; private set; } public Action? ScalarChanged { get; set; } + /// + /// Optional live scalar reader used by plugin markup. It is sampled while + /// no thumb gesture is active so external/profile changes reach the widget + /// without fighting the value under the user's cursor. + /// + public Func? ScalarPositionSource { get; set; } public bool Horizontal { get; set; } /// True while a thumb drag is in progress (between a thumb-hit @@ -94,6 +100,13 @@ public sealed class UiScrollbar : UiElement public void SetScalarPosition(float position) => ScalarPosition = Math.Clamp(position, 0f, 1f); + protected override void OnTick(double deltaSeconds) + { + base.OnTick(deltaSeconds); + if (!_draggingThumb && ScalarPositionSource?.Invoke() is { } value) + SetScalarPosition(value); + } + /// Settable tooltip, surfaced through the shared /// hover pipeline — the SAME /// pattern already established @@ -105,7 +118,9 @@ public sealed class UiScrollbar : UiElement /// public override string? GetTooltipText() => - string.IsNullOrWhiteSpace(TooltipText) ? null : TooltipText; + string.IsNullOrWhiteSpace(TooltipText) + ? base.GetTooltipText() + : TooltipText; /// RenderSurface id → (GL tex, w, h). 0 id = skip. public Func? SpriteResolve { get; set; } diff --git a/src/AcDream.App/World/LiveEntityDeletionController.cs b/src/AcDream.App/World/LiveEntityDeletionController.cs index 8bc67bf0..95021483 100644 --- a/src/AcDream.App/World/LiveEntityDeletionController.cs +++ b/src/AcDream.App/World/LiveEntityDeletionController.cs @@ -65,6 +65,21 @@ internal sealed class LiveEntityDeletionController : ILiveEntityPruneSink return removed || removedDormant; } + /// + /// VTank ghost cleanup: synthesize the exact current incarnation delete, + /// then use the same complete teardown transaction as a server DeleteObject. + /// + public bool DeleteClientGhost(uint serverGuid) + { + if (serverGuid == 0u + || serverGuid == _identity.ServerGuid + || !_runtime.TryGetRecord(serverGuid, out LiveEntityRecord record)) + { + return false; + } + return Delete(new DeleteObject.Parsed(serverGuid, record.Generation)); + } + public bool Prune(LiveEntityPruneCandidate candidate) { if (!_runtime.TryGetRecord( diff --git a/src/AcDream.Content/MagicCatalog.cs b/src/AcDream.Content/MagicCatalog.cs index f6a8f52a..0992ea9d 100644 --- a/src/AcDream.Content/MagicCatalog.cs +++ b/src/AcDream.Content/MagicCatalog.cs @@ -15,7 +15,15 @@ public sealed record SpellComponentDescriptor( uint WeenieClassId, string Name, uint Category, - uint IconId); + uint IconId) +{ + public uint SpellComponentId { get; init; } + public double BurnRate { get; init; } + public uint GestureId { get; init; } + public double GestureSpeed { get; init; } + public string Type { get; init; } = string.Empty; + public string Word { get; init; } = string.Empty; +} /// /// Process-shareable projection of retail's spell, component, and @@ -142,7 +150,15 @@ public sealed class MagicCatalog wcid, pair.Value.Name.Value, pair.Value.Category, - pair.Value.Icon.DataId); + pair.Value.Icon.DataId) + { + SpellComponentId = pair.Key, + BurnRate = pair.Value.CDM, + GestureId = pair.Value.Gesture, + GestureSpeed = pair.Value.Time, + Type = pair.Value.Type.ToString(), + Word = pair.Value.Text.Value, + }; } } diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 3ce26e23..f34d9723 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -1004,9 +1004,14 @@ public static class GameEventWiring { var p = AppraiseInfoParser.TryParse(e.Payload.Span); if (p is null) return; - // Merge parsed properties into the item if we know about it. + // Retain the property tables and the item's own spell manifest as + // one projection. VTank consults the latter for proc weapons. if (p.Value.Success && items.Get(p.Value.Guid) is not null) - items.UpdateProperties(p.Value.Guid, p.Value.Properties); + items.UpdateAppraisal( + p.Value.Guid, + p.Value.Properties, + p.Value.SpellBook, + clientTime()); if (p.Value.CreatureProfile is { HealthMax: > 0u } creature) combat.OnUpdateHealth( p.Value.Guid, @@ -1020,8 +1025,8 @@ public static class GameEventWiring // spellbook arrives via PlayerDescription (0x0013), which uses // a different wire format (see WorldSession + LocalPlayerState // — feeds vitals from PrivateUpdateVital instead). - // The appraised spellbook belongs to that item. The local player's - // learned spell manifest arrives only in PlayerDescription. + // The appraised spellbook now belongs to that item. The local + // player's learned manifest arrives only in PlayerDescription. }); // ── Player ──────────────────────────────────────────────── diff --git a/src/AcDream.Core.Net/Messages/InventoryActions.cs b/src/AcDream.Core.Net/Messages/InventoryActions.cs index c88a9cef..8bf41bd6 100644 --- a/src/AcDream.Core.Net/Messages/InventoryActions.cs +++ b/src/AcDream.Core.Net/Messages/InventoryActions.cs @@ -27,6 +27,7 @@ public static class InventoryActions public const uint DropItemOpcode = 0x001Bu; public const uint NoLongerViewingContentsOpcode = 0x0195u; public const uint SetInscriptionOpcode = 0x00BFu; + public const uint CreateTinkeringToolOpcode = 0x027Du; /// /// Merge stack A into stack B of the same item type. Server validates @@ -205,4 +206,48 @@ public static class InventoryActions text.CopyTo(body, 18); return body; } + + /// + /// Salvage one or more carried items with an owned salvage tool. Retail + /// CM_Inventory::Event_CreateTinkeringTool @ 0x006AB830 writes the + /// tool id followed by PackableList<unsigned long>: u32 count, + /// then the object ids in list order. The server replies with GameEvent + /// 0x02B4 SalvageOperationsResult and removes accepted source items. + /// + public static byte[] BuildCreateTinkeringTool( + uint seq, + uint toolGuid, + IReadOnlyList itemGuids) + { + ArgumentNullException.ThrowIfNull(itemGuids); + if (toolGuid == 0u) + throw new ArgumentOutOfRangeException(nameof(toolGuid)); + if (itemGuids.Count == 0) + throw new ArgumentException( + "At least one item is required for salvage.", + nameof(itemGuids)); + + byte[] body = new byte[20 + (itemGuids.Count * sizeof(uint))]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); + BinaryPrimitives.WriteUInt32LittleEndian( + body.AsSpan(8), + CreateTinkeringToolOpcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), toolGuid); + BinaryPrimitives.WriteUInt32LittleEndian( + body.AsSpan(16), + checked((uint)itemGuids.Count)); + for (int index = 0; index < itemGuids.Count; index++) + { + uint itemGuid = itemGuids[index]; + if (itemGuid == 0u) + throw new ArgumentException( + "Salvage item ids must be non-zero.", + nameof(itemGuids)); + BinaryPrimitives.WriteUInt32LittleEndian( + body.AsSpan(20 + (index * sizeof(uint))), + itemGuid); + } + return body; + } } diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 08efe5c0..6bc9ea59 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -3268,6 +3268,19 @@ public sealed class WorldSession : IDisposable SendGameAction(InventoryActions.BuildStackableSplitTo3D(seq, stackGuid, amount)); } + /// + /// Send retail CreateTinkeringTool (0x027D), the salvage operation used by + /// gmSalvageUI and VTank. + /// + public void SendSalvage(uint toolGuid, IReadOnlyList itemGuids) + { + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildCreateTinkeringTool( + seq, + toolGuid, + itemGuids)); + } + /// Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0). /// /// Retail anchor: CM_Combat::Event_QueryHealth / gmToolbarUI::HandleSelectionChanged:198635 diff --git a/src/AcDream.Core.Net/packages.win-x64.lock.json b/src/AcDream.Core.Net/packages.win-x64.lock.json index c2855510..64561bb2 100644 --- a/src/AcDream.Core.Net/packages.win-x64.lock.json +++ b/src/AcDream.Core.Net/packages.win-x64.lock.json @@ -2,55 +2,6 @@ "version": 2, "dependencies": { "net10.0": { - "BCnEncoder.Net": { - "type": "Direct", - "requested": "[2.2.1, )", - "resolved": "2.2.1", - "contentHash": "tI5+/OQo0kciLqWrViRjpOH+IL3FjexYnoWZajiGV41g/EM9CGbWsxsPzBDmpoxNkrV9uox/EtIhCIi9chBSFw==", - "dependencies": { - "CommunityToolkit.HighPerformance": "8.4.0" - } - }, - "Chorizite.Core": { - "type": "Direct", - "requested": "[0.0.18, )", - "resolved": "0.0.18", - "contentHash": "Pvf5idSsN0NfhZspJhrpo7QiNTmHx3mnKFkmAvHFpEFx+RnqfXh96Q+Pxam+hrqXbimmKGfk1ooIl5kpQcMo6w==", - "dependencies": { - "Autofac": "8.4.0", - "Chorizite.ACProtocol": "1.0.1", - "Chorizite.Common": "1.0.3", - "Chorizite.DatReaderWriter": "1.0.0", - "FontStashSharp": "1.3.10", - "Microsoft.Diagnostics.Runtime": "3.1.512801", - "Microsoft.Extensions.Logging.Abstractions": "9.0.9", - "NJsonSchema": "11.5.1", - "SixLabors.ImageSharp": "3.1.11", - "SixLabors.ImageSharp.Drawing": "2.1.7" - } - }, - "Chorizite.DatReaderWriter": { - "type": "Direct", - "requested": "[2.1.7, )", - "resolved": "2.1.7", - "contentHash": "6CpUhfHDV/O+lNx0xUZUcXK7wGkEkHYCnGHFtD8BBeAv4i1BuaNfTID+VQoSzx2EtmOVM0A2O/9wRFibApz6rQ==", - "dependencies": { - "DotNet.Standard.Common": "2.0.1", - "ZLibDotNet": "0.1.1" - } - }, - "Serilog": { - "type": "Direct", - "requested": "[4.0.2, )", - "resolved": "4.0.2", - "contentHash": "Vehq4uNYtURe/OnHEpWGvMgrvr5Vou7oZLdn3BuEH5FSCeHXDpNJtpzWoqywXsSvCTuiv0I65mZDRnJSeUvisA==" - }, - "StbImageSharp": { - "type": "Direct", - "requested": "[2.30.16, )", - "resolved": "2.30.16", - "contentHash": "qg1i+NHihXVKLKYacGKrauhSQIGL31eBWcTC4Vc7jnGmBFj87LkRuCdXB5aDZisWMRnB6x2mcfwYXQxMWEG/lw==" - }, "Autofac": { "type": "Transitive", "resolved": "8.4.0", @@ -245,9 +196,57 @@ "resolved": "0.1.1", "contentHash": "QEti4O7dwRcOb9zbnLuudSrt2IT61OYjq0R7lcJb+EzUm5N6djOVGU/cp+FZIZzJUnaFIntwrpwiuRvhpS7ZHg==" }, + "acdream.core": { + "type": "Project", + "dependencies": { + "AcDream.Plugin.Abstractions": "[1.0.0, )", + "BCnEncoder.Net": "[2.2.1, )", + "Chorizite.Core": "[0.0.18, )", + "Chorizite.DatReaderWriter": "[2.1.7, )", + "Serilog": "[4.0.2, )", + "StbImageSharp": "[2.30.16, )" + } + }, "acdream.plugin.abstractions": { "type": "Project" }, + "BCnEncoder.Net": { + "type": "CentralTransitive", + "requested": "[2.2.1, )", + "resolved": "2.2.1", + "contentHash": "tI5+/OQo0kciLqWrViRjpOH+IL3FjexYnoWZajiGV41g/EM9CGbWsxsPzBDmpoxNkrV9uox/EtIhCIi9chBSFw==", + "dependencies": { + "CommunityToolkit.HighPerformance": "8.4.0" + } + }, + "Chorizite.Core": { + "type": "CentralTransitive", + "requested": "[0.0.18, )", + "resolved": "0.0.18", + "contentHash": "Pvf5idSsN0NfhZspJhrpo7QiNTmHx3mnKFkmAvHFpEFx+RnqfXh96Q+Pxam+hrqXbimmKGfk1ooIl5kpQcMo6w==", + "dependencies": { + "Autofac": "8.4.0", + "Chorizite.ACProtocol": "1.0.1", + "Chorizite.Common": "1.0.3", + "Chorizite.DatReaderWriter": "1.0.0", + "FontStashSharp": "1.3.10", + "Microsoft.Diagnostics.Runtime": "3.1.512801", + "Microsoft.Extensions.Logging.Abstractions": "9.0.9", + "NJsonSchema": "11.5.1", + "SixLabors.ImageSharp": "3.1.11", + "SixLabors.ImageSharp.Drawing": "2.1.7" + } + }, + "Chorizite.DatReaderWriter": { + "type": "CentralTransitive", + "requested": "[2.1.7, )", + "resolved": "2.1.7", + "contentHash": "6CpUhfHDV/O+lNx0xUZUcXK7wGkEkHYCnGHFtD8BBeAv4i1BuaNfTID+VQoSzx2EtmOVM0A2O/9wRFibApz6rQ==", + "dependencies": { + "DotNet.Standard.Common": "2.0.1", + "ZLibDotNet": "0.1.1" + } + }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", "requested": "[9.0.9, )", @@ -257,12 +256,24 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9" } }, + "Serilog": { + "type": "CentralTransitive", + "requested": "[4.0.2, )", + "resolved": "4.0.2", + "contentHash": "Vehq4uNYtURe/OnHEpWGvMgrvr5Vou7oZLdn3BuEH5FSCeHXDpNJtpzWoqywXsSvCTuiv0I65mZDRnJSeUvisA==" + }, "SixLabors.ImageSharp": { "type": "CentralTransitive", "requested": "[3.1.12, )", "resolved": "3.1.11", "contentHash": "JfPLyigLthuE50yi6tMt7Amrenr/fA31t2CvJyhy/kQmfulIBAqo5T/YFUSRHtuYPXRSaUHygFeh6Qd933EoSw==" }, + "StbImageSharp": { + "type": "CentralTransitive", + "requested": "[2.30.16, )", + "resolved": "2.30.16", + "contentHash": "qg1i+NHihXVKLKYacGKrauhSQIGL31eBWcTC4Vc7jnGmBFj87LkRuCdXB5aDZisWMRnB6x2mcfwYXQxMWEG/lw==" + }, "StbTrueTypeSharp": { "type": "CentralTransitive", "requested": "[1.26.12, )", diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs index 42a99890..40ea3b74 100644 --- a/src/AcDream.Core/Items/ClientObject.cs +++ b/src/AcDream.Core/Items/ClientObject.cs @@ -251,6 +251,21 @@ public sealed class ClientObject /// Retail PublicWeenieDesc._spellID; used by caster endowments. public uint? SpellId { get; set; } /// + /// Spell ids retained from this item's latest successful + /// IdentifyObjectResponse SpellBook block. These are item spells, + /// not the local character's learned spellbook; VTank uses them to + /// classify cast-on-strike weapons and item-cast debuffs. + /// + public IReadOnlyList AppraisedSpellIds { get; internal set; } = + Array.Empty(); + /// + /// Monotonic millisecond tick at which the latest successful identify + /// response was received. This is Decal's per-world-object + /// LastIdTime, retained on the canonical object so it disappears + /// with that exact object lifetime. + /// + public int LastAppraisalTimeMs { get; internal set; } + /// /// Retail PublicWeenieDesc._cooldown_id. Positive values name a /// shared item-cooldown group whose player enchantment id is /// CooldownId + 0x8000. diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 0faeca46..8b75e595 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -774,6 +774,45 @@ public sealed class ClientObjectTable public bool UpdateProperties(uint itemId, PropertyBundle incoming) { if (!_objects.TryGetValue(itemId, out var item)) return false; + MergeProperties(item, incoming); + ApplyCooldownProperties(item, incoming); + ObjectUpdated?.Invoke(item); + return true; + } + + /// + /// Atomically retains every successful item-appraisal result: the typed + /// property tables and the per-item SpellBook block. Publishing one update + /// prevents observers from seeing properties without their matching spell + /// manifest (or the reverse). + /// + public bool UpdateAppraisal( + uint itemId, + PropertyBundle incoming, + IReadOnlyList spellIds, + double receivedAtSeconds = 0d) + { + ArgumentNullException.ThrowIfNull(incoming); + ArgumentNullException.ThrowIfNull(spellIds); + if (!_objects.TryGetValue(itemId, out var item)) return false; + MergeProperties(item, incoming); + item.AppraisedSpellIds = spellIds.Count == 0 + ? Array.Empty() + : spellIds.ToArray(); + if (double.IsFinite(receivedAtSeconds) && receivedAtSeconds >= 0d) + { + long milliseconds = checked((long)Math.Round( + receivedAtSeconds * 1000d, + MidpointRounding.AwayFromZero)); + item.LastAppraisalTimeMs = unchecked((int)milliseconds); + } + ApplyCooldownProperties(item, incoming); + ObjectUpdated?.Invoke(item); + return true; + } + + private static void MergeProperties(ClientObject item, PropertyBundle incoming) + { foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value; foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value; foreach (var kv in incoming.Bools) item.Properties.Bools[kv.Key] = kv.Value; @@ -781,9 +820,6 @@ public sealed class ClientObjectTable foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value; foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value; foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value; - ApplyCooldownProperties(item, incoming); - ObjectUpdated?.Invoke(item); - return true; } /// diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 103e4d20..014df169 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -2383,7 +2383,11 @@ public sealed class PhysicsEngine body is not null ? PhysicsResolveCapture.Snapshot(body) : null); } - return resolveResult; + return resolveResult with + { + LastCollidedObjectId = ci.LastCollidedObjectGuid ?? 0u, + CollidedWithEnvironment = ci.CollidedWithEnvironment, + }; } finally { diff --git a/src/AcDream.Core/Physics/ResolveResult.cs b/src/AcDream.Core/Physics/ResolveResult.cs index 28733b8d..49d2b2f5 100644 --- a/src/AcDream.Core/Physics/ResolveResult.cs +++ b/src/AcDream.Core/Physics/ResolveResult.cs @@ -58,4 +58,16 @@ public readonly record struct ResolveResult( /// Full cell that owns . uint ContactPlaneCellId = 0, /// Whether the accepted contact plane is water. - bool ContactPlaneIsWater = false); + bool ContactPlaneIsWater = false) +{ + /// + /// Last live object touched by this transition, or zero for environment- + /// only/no collision. This is detached collision evidence, not an impact + /// side effect; projectile-awareness callers use it to distinguish the + /// designated target from an intervening creature or prop. + /// + public uint LastCollidedObjectId { get; init; } + + /// Whether resident environment geometry blocked the sweep. + public bool CollidedWithEnvironment { get; init; } +} diff --git a/src/AcDream.Core/Plugins/PluginCommandRegistry.cs b/src/AcDream.Core/Plugins/PluginCommandRegistry.cs new file mode 100644 index 00000000..a162bb8b --- /dev/null +++ b/src/AcDream.Core/Plugins/PluginCommandRegistry.cs @@ -0,0 +1,142 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Plugins; + +/// +/// Shared host implementation of the additive plugin-command contract. +/// Registrations are exact leases; callbacks are invoked outside the registry +/// lock so a handler may submit chat or unregister itself without deadlocking. +/// +public sealed class PluginCommandRegistry : IPluginCommandRegistry +{ + private readonly object _gate = new(); + private readonly Dictionary _registrations = + new(StringComparer.OrdinalIgnoreCase); + private readonly Action? _onFailure; + + public PluginCommandRegistry(Action? onFailure = null) + { + _onFailure = onFailure; + } + + public IDisposable Register(string verb, Action handler) + { + string normalized = NormalizeVerb(verb); + ArgumentNullException.ThrowIfNull(handler); + var registration = new Registration(this, normalized, handler); + lock (_gate) + { + if (_registrations.ContainsKey(normalized)) + { + throw new InvalidOperationException( + $"Plugin command '{normalized}' is already registered."); + } + _registrations.Add(normalized, registration); + } + return registration; + } + + /// Try to consume one complete command-shaped line. + public bool TryHandle(string rawText) + { + if (string.IsNullOrWhiteSpace(rawText)) + return false; + string trimmed = rawText.Trim(); + if (trimmed.Length < 2 || trimmed[0] is not ('/' or '@')) + return false; + + int separator = trimmed.IndexOfAny([' ', '\t'], 1); + string verb = separator < 0 + ? trimmed[1..] + : trimmed[1..separator]; + if (verb.Length == 0) + return false; + + Registration? registration; + lock (_gate) + _registrations.TryGetValue(verb, out registration); + if (registration is null) + return false; + + string arguments = separator < 0 + ? string.Empty + : trimmed[(separator + 1)..].Trim(); + try + { + registration.Invoke(new PluginCommand( + registration.Verb, + arguments, + trimmed)); + } + catch (Exception error) + { + try + { + _onFailure?.Invoke(registration.Verb, error); + } + catch + { + // Diagnostics observe plugin code; they cannot poison chat. + } + } + return true; + } + + private static string NormalizeVerb(string verb) + { + ArgumentException.ThrowIfNullOrWhiteSpace(verb); + string normalized = verb.Trim().TrimStart('/', '@'); + if (normalized.Length is < 1 or > 32 + || normalized.Any(static value => !char.IsLetterOrDigit(value))) + { + throw new ArgumentException( + "Plugin command verbs must contain 1-32 letters or digits.", + nameof(verb)); + } + return normalized; + } + + private void Remove(Registration expected) + { + lock (_gate) + { + if (_registrations.TryGetValue(expected.Verb, out Registration? current) + && ReferenceEquals(current, expected)) + { + _registrations.Remove(expected.Verb); + } + } + } + + private sealed class Registration( + PluginCommandRegistry owner, + string verb, + Action handler) : IDisposable + { + private readonly object _gate = new(); + private PluginCommandRegistry? _owner = owner; + private Action? _handler = handler; + + internal string Verb { get; } = verb; + + internal void Invoke(PluginCommand command) + { + Action? callback; + lock (_gate) + callback = _handler; + callback?.Invoke(command); + } + + public void Dispose() + { + PluginCommandRegistry? currentOwner; + lock (_gate) + { + currentOwner = _owner; + _owner = null; + _handler = null; + } + currentOwner?.Remove(this); + } + } +} diff --git a/src/AcDream.Core/Plugins/PluginLootClassifierRegistry.cs b/src/AcDream.Core/Plugins/PluginLootClassifierRegistry.cs new file mode 100644 index 00000000..ecccadf8 --- /dev/null +++ b/src/AcDream.Core/Plugins/PluginLootClassifierRegistry.cs @@ -0,0 +1,151 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Plugins; + +/// Process-local transactional registry for external loot plugins. +public sealed class PluginLootClassifierRegistry : IPluginLootClassifierRegistry +{ + private readonly object _gate = new(); + private readonly Dictionary _entries = + new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList Available + { + get + { + lock (_gate) + { + return _entries.Values + .Select(static entry => entry.Info) + .OrderBy(static info => info.DisplayName, + StringComparer.OrdinalIgnoreCase) + .ThenBy(static info => info.Id, + StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + } + } + + public IDisposable Register( + string classifierId, + string displayName, + IPluginLootClassifier classifier) + { + ArgumentException.ThrowIfNullOrWhiteSpace(classifierId); + ArgumentException.ThrowIfNullOrWhiteSpace(displayName); + ArgumentNullException.ThrowIfNull(classifier); + string id = classifierId.Trim(); + var entry = new Entry( + new PluginLootClassifierInfo(id, displayName.Trim()), + classifier); + lock (_gate) + { + if (!_entries.TryAdd(id, entry)) + { + throw new InvalidOperationException( + $"Loot classifier '{id}' is already registered."); + } + } + return new Registration(this, id, entry); + } + + public bool TryClassify( + string classifierId, + in PluginLootClassificationContext context, + out PluginLootClassification classification) + { + Entry? entry; + lock (_gate) + _entries.TryGetValue(classifierId ?? string.Empty, out entry); + if (entry is null) + { + classification = default; + return false; + } + try + { + classification = entry.Classifier.Classify(context); + return true; + } + catch + { + classification = default; + return false; + } + } + + public bool TryNotifyLooted( + string classifierId, + in PluginLootedItem item) + { + if (!TryGetClassifier(classifierId, out IPluginLootClassifier classifier)) + return false; + try + { + classifier.OnLooted(item); + return true; + } + catch + { + return false; + } + } + + public bool TryNotifyItemRemoved(string classifierId, uint objectId) + { + if (!TryGetClassifier(classifierId, out IPluginLootClassifier classifier)) + return false; + try + { + classifier.OnItemRemoved(objectId); + return true; + } + catch + { + return false; + } + } + + private bool TryGetClassifier( + string classifierId, + out IPluginLootClassifier classifier) + { + classifier = null!; + if (string.IsNullOrWhiteSpace(classifierId)) + return false; + lock (_gate) + { + if (!_entries.TryGetValue(classifierId.Trim(), out Entry? entry)) + return false; + classifier = entry.Classifier; + return true; + } + } + + private void Remove(string id, Entry expected) + { + lock (_gate) + { + if (_entries.TryGetValue(id, out Entry? current) + && ReferenceEquals(current, expected)) + { + _entries.Remove(id); + } + } + } + + private sealed record Entry( + PluginLootClassifierInfo Info, + IPluginLootClassifier Classifier); + + private sealed class Registration( + PluginLootClassifierRegistry owner, + string id, + Entry entry) : IDisposable + { + private PluginLootClassifierRegistry? _owner = owner; + + public void Dispose() => Interlocked.Exchange(ref _owner, null)? + .Remove(id, entry); + } +} diff --git a/src/AcDream.Core/Plugins/PluginSession.cs b/src/AcDream.Core/Plugins/PluginSession.cs index 82d1a2d3..4aa5b6b2 100644 --- a/src/AcDream.Core/Plugins/PluginSession.cs +++ b/src/AcDream.Core/Plugins/PluginSession.cs @@ -258,7 +258,10 @@ public sealed class PluginSession : IDisposable { foreach (PluginDiscoveryResult candidate in available) { - var scope = new ScopedPluginHost(_host); + var scope = new ScopedPluginHost( + _host, + candidate.Manifest!.Id, + candidate.Manifest.DisplayName); ScopedRenderPackRegistry? renderPackScope = candidate.Manifest!.Declares(PluginKind.RenderPack) && _renderPacks is not null diff --git a/src/AcDream.Core/Plugins/ScopedPluginHost.cs b/src/AcDream.Core/Plugins/ScopedPluginHost.cs index 9cd8179b..852bf063 100644 --- a/src/AcDream.Core/Plugins/ScopedPluginHost.cs +++ b/src/AcDream.Core/Plugins/ScopedPluginHost.cs @@ -13,14 +13,30 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable private readonly ScopedEvents _events; private readonly ScopedSelectionService _selection; private readonly ScopedUiRegistry _ui; + private readonly ScopedPluginStorage _storage; + private readonly ScopedPluginCommandRegistry _commands; + private readonly ScopedLootClassifierRegistry _lootClassifiers; private bool _disposed; - internal ScopedPluginHost(IPluginHost inner) + internal ScopedPluginHost( + IPluginHost inner, + string pluginId, + string pluginDisplayName) { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + ArgumentException.ThrowIfNullOrWhiteSpace(pluginId); + ArgumentException.ThrowIfNullOrWhiteSpace(pluginDisplayName); _events = new ScopedEvents(inner.Events); _selection = new ScopedSelectionService(inner.Selection); - _ui = new ScopedUiRegistry(inner.Ui); + _ui = new ScopedUiRegistry( + inner.Ui, + new PluginUiOwner(pluginId, pluginDisplayName)); + _storage = new ScopedPluginStorage(inner.Storage, pluginId); + _commands = new ScopedPluginCommandRegistry(inner.Commands); + _lootClassifiers = new ScopedLootClassifierRegistry( + inner.LootClassifiers, + pluginId, + pluginDisplayName); } public bool HasUi => _inner.HasUi; @@ -29,6 +45,9 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable public IEvents Events => _events; public ISelectionService Selection => _selection; public IUiRegistry Ui => _ui; + public IPluginStorage Storage => _storage; + public IPluginCommandRegistry Commands => _commands; + public IPluginLootClassifierRegistry LootClassifiers => _lootClassifiers; /// /// Delegated rather than scoped, unlike , @@ -45,6 +64,48 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable /// public IAutomationSurface Automation => _inner.Automation; + private sealed class ScopedPluginStorage( + IPluginStorage inner, + string pluginId) : IPluginStorage + { + public bool IsAvailable => inner.IsAvailable; + public string? ReadText(string key) => + inner.ReadText(ScopedKey(key)); + public IReadOnlyList List(string prefix) + { + string scopedPrefix = ScopedKey(prefix); + string ownerPrefix = pluginId + Path.DirectorySeparatorChar; + return inner.List(scopedPrefix) + .Select(key => key.Replace('/', Path.DirectorySeparatorChar)) + .Where(key => key.StartsWith( + ownerPrefix, + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal)) + .Select(key => key[ownerPrefix.Length..] + .Replace(Path.DirectorySeparatorChar, '/')) + .ToArray(); + } + public void WriteText(string key, string content) => + inner.WriteText(ScopedKey(key), content); + public bool Delete(string key) => inner.Delete(ScopedKey(key)); + + private static string ValidateKey(string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + if (Path.IsPathRooted(key) + || key.Contains("..", StringComparison.Ordinal) + || key.Contains('\\')) + { + throw new ArgumentException("Invalid plugin storage key.", nameof(key)); + } + return key.Replace('/', Path.DirectorySeparatorChar); + } + + private string ScopedKey(string key) => + Path.Combine(pluginId, ValidateKey(key)); + } + public void Dispose() { if (_disposed) @@ -53,6 +114,127 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable _events.Dispose(); _selection.Dispose(); _ui.Dispose(); + _commands.Dispose(); + _lootClassifiers.Dispose(); + } + + private sealed class ScopedLootClassifierRegistry( + IPluginLootClassifierRegistry inner, + string pluginId, + string pluginDisplayName) + : IPluginLootClassifierRegistry, + IDisposable + { + private readonly object _gate = new(); + private readonly List _registrations = []; + private bool _disposed; + + public IReadOnlyList Available => + inner.Available; + + public IDisposable Register( + string classifierId, + string displayName, + IPluginLootClassifier classifier) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentException.ThrowIfNullOrWhiteSpace(classifierId); + string local = classifierId.Trim(); + if (local.Contains('/') || local.Contains('\\')) + { + throw new ArgumentException( + "A classifier id cannot contain a path separator.", + nameof(classifierId)); + } + string effectiveName = string.IsNullOrWhiteSpace(displayName) + ? pluginDisplayName + : displayName.Trim(); + IDisposable registration = inner.Register( + $"{pluginId}/{local}", + effectiveName, + classifier); + lock (_gate) + { + if (!_disposed) + { + _registrations.Add(registration); + return registration; + } + } + registration.Dispose(); + throw new ObjectDisposedException(nameof(ScopedLootClassifierRegistry)); + } + + public bool TryClassify( + string classifierId, + in PluginLootClassificationContext context, + out PluginLootClassification classification) => + inner.TryClassify(classifierId, context, out classification); + + public bool TryNotifyLooted( + string classifierId, + in PluginLootedItem item) => + inner.TryNotifyLooted(classifierId, item); + + public bool TryNotifyItemRemoved( + string classifierId, + uint objectId) => + inner.TryNotifyItemRemoved(classifierId, objectId); + + public void Dispose() + { + IDisposable[] registrations; + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + registrations = _registrations.ToArray(); + _registrations.Clear(); + } + for (int index = registrations.Length - 1; index >= 0; index--) + registrations[index].Dispose(); + } + } + + private sealed class ScopedPluginCommandRegistry(IPluginCommandRegistry inner) + : IPluginCommandRegistry, + IDisposable + { + private readonly object _gate = new(); + private readonly List _registrations = []; + private bool _disposed; + + public IDisposable Register(string verb, Action handler) + { + ObjectDisposedException.ThrowIf(_disposed, this); + IDisposable registration = inner.Register(verb, handler); + lock (_gate) + { + if (!_disposed) + { + _registrations.Add(registration); + return registration; + } + } + registration.Dispose(); + throw new ObjectDisposedException(nameof(ScopedPluginCommandRegistry)); + } + + public void Dispose() + { + IDisposable[] registrations; + lock (_gate) + { + if (_disposed) + return; + _disposed = true; + registrations = _registrations.ToArray(); + _registrations.Clear(); + } + for (int index = registrations.Length - 1; index >= 0; index--) + registrations[index].Dispose(); + } } private sealed class ScopedSelectionService(ISelectionService inner) @@ -297,22 +479,92 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable private sealed class ScopedUiRegistry : IUiRegistry, IDisposable { private readonly IScopedUiRegistry _inner; + private readonly PluginUiOwner _owner; private readonly object _gate = new(); private readonly List _registrations = []; private bool _disposed; - internal ScopedUiRegistry(IUiRegistry inner) + internal ScopedUiRegistry(IUiRegistry inner, PluginUiOwner owner) { _inner = inner as IScopedUiRegistry ?? throw new InvalidOperationException( "Plugin hosts must expose an IScopedUiRegistry so UI registrations can be rolled back."); + _owner = owner; } public void AddMarkupPanel(string markupPath, object binding) { - IDisposable registration = _inner.RegisterMarkupPanel( + AddRegistration(_inner.RegisterPanel( + _owner, + new PluginPanelDescriptor( + Path.GetFileNameWithoutExtension(markupPath), + _owner.DisplayName), markupPath, - binding); + binding)); + } + + public void AddPanel( + PluginPanelDescriptor descriptor, + string markupPath, + object binding) + { + ArgumentNullException.ThrowIfNull(descriptor); + AddRegistration(_inner.RegisterPanel( + _owner, + descriptor, + markupPath, + binding)); + } + + public IDisposable RegisterPanel( + PluginPanelDescriptor descriptor, + string markupPath, + object binding) + { + ArgumentNullException.ThrowIfNull(descriptor); + return TrackRegistration(_inner.RegisterPanel( + _owner, + descriptor, + markupPath, + binding)); + } + + public IDisposable RegisterPanelContent( + PluginPanelDescriptor descriptor, + string markupContent, + object binding) + { + ArgumentNullException.ThrowIfNull(descriptor); + return TrackRegistration(_inner.RegisterPanelContent( + _owner, + descriptor, + markupContent, + binding)); + } + + public bool ViewExists(string viewName) => + _inner.ViewExists(_owner, viewName); + + public bool IsViewVisible(string viewName) => + _inner.IsViewVisible(_owner, viewName); + + public bool ControlExists(string viewName, string controlName) => + _inner.ControlExists(_owner, viewName, controlName); + + public bool SetControlLabel( + string viewName, + string controlName, + string label) => + _inner.SetControlLabel(_owner, viewName, controlName, label); + + public bool SetControlVisible( + string viewName, + string controlName, + bool visible) => + _inner.SetControlVisible(_owner, viewName, controlName, visible); + + private void AddRegistration(IDisposable registration) + { lock (_gate) { if (!_disposed) @@ -326,6 +578,31 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable throw new ObjectDisposedException(nameof(ScopedUiRegistry)); } + private IDisposable TrackRegistration(IDisposable registration) + { + lock (_gate) + { + if (!_disposed) + { + _registrations.Add(registration); + return new IndividualRegistration(this, registration); + } + } + + registration.Dispose(); + throw new ObjectDisposedException(nameof(ScopedUiRegistry)); + } + + private void RemoveRegistration(IDisposable registration) + { + lock (_gate) + { + if (!_registrations.Remove(registration)) + return; + } + registration.Dispose(); + } + public void Dispose() { IDisposable[] registrations; @@ -344,5 +621,15 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable catch { } } } + + private sealed class IndividualRegistration( + ScopedUiRegistry owner, + IDisposable registration) : IDisposable + { + private ScopedUiRegistry? _owner = owner; + + public void Dispose() => Interlocked.Exchange(ref _owner, null)? + .RemoveRegistration(registration); + } } } diff --git a/src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs b/src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs index bafa7a94..0fa42185 100644 --- a/src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessLocalPlayerFrameHost.cs @@ -15,7 +15,7 @@ internal sealed class HeadlessMovementInputSource( public MovementInput Capture() { if (_movement.HasCommandInput) - return _movement.CommandInput; + return _movement.CommandInput with { IsPersistentCommand = true }; return new MovementInput( Forward: _movement.AutoRunActive, Run: true); diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index a0140d36..46ab383f 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -341,7 +341,13 @@ internal sealed class HeadlessSessionHost : IDisposable // descriptor.StatusFile is unset — every call site below stays // unconditional. var statusWriter = new SessionStatusWriter(descriptor.StatusFile); - var chatCommandSurface = new LiveChatCommandSurface(); + var pluginCommands = new AcDream.Core.Plugins.PluginCommandRegistry( + (verb, error) => diagnostics.Failure( + descriptor.Id, + $"plugin-command-{verb}", + error)); + var chatCommandSurface = new LiveChatCommandSurface( + pluginCommands.TryHandle); var loginCommands = new LoginCommandSequence( descriptor.LoginCommands, TimeSpan.FromMilliseconds(descriptor.LoginCommandDelayMs), @@ -359,7 +365,8 @@ internal sealed class HeadlessSessionHost : IDisposable statusWriter, descriptor.Id, pluginRoots ?? [], - descriptor.Plugins); + descriptor.Plugins, + pluginCommands); var liveSession = new LiveSessionHost( runtime.Session, new LiveSessionHostBindings( @@ -1201,6 +1208,7 @@ internal sealed class HeadlessSessionHost : IDisposable { Runtime.InventoryOwner.ExternalContainers .ApplyUseDone(error); + Runtime.ActionOwner.SpellCast.CompleteUse(error); Runtime.ActionOwner.Transactions.CompleteUse(error); }, Runtime.InventoryOwner.ItemMana, diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs index 45b99503..23909378 100644 --- a/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs +++ b/src/AcDream.Headless/Plugins/HeadlessPluginHost.cs @@ -38,15 +38,18 @@ internal sealed class HeadlessPluginHost internal HeadlessPluginHost( GameRuntime runtime, - IPluginLogger logger) + IPluginLogger logger, + IPluginCommandRegistry? commands = null) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); Log = logger ?? throw new ArgumentNullException(nameof(logger)); + Commands = commands ?? NoOpPluginCommandRegistry.Instance; _eventSubscription = runtime.Subscribe(this); } public bool HasUi => false; public IPluginLogger Log { get; } + public IPluginCommandRegistry Commands { get; } public IGameState State => this; public IEvents Events => this; public ISelectionService Selection => _runtime.ActionOwner.Selection; diff --git a/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs index 6791670f..9110cb07 100644 --- a/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs +++ b/src/AcDream.Headless/Plugins/HeadlessPluginSession.cs @@ -44,7 +44,8 @@ internal sealed class HeadlessPluginSession : IDisposable SessionStatusWriter statusWriter, string sessionId, IEnumerable roots, - IReadOnlyList? allowList) + IReadOnlyList? allowList, + IPluginCommandRegistry? commands = null) { ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(diagnostics); @@ -57,7 +58,8 @@ internal sealed class HeadlessPluginSession : IDisposable new HeadlessPluginLogger( diagnostics, sessionId, - () => runtime.Generation.Value)); + () => runtime.Generation.Value), + commands); var plugins = new PluginSession( host, status => Report(statusWriter, sessionId, status), diff --git a/src/AcDream.Plugin.Abstractions/Automation.cs b/src/AcDream.Plugin.Abstractions/Automation.cs index 23a0a00c..dd7a11d3 100644 --- a/src/AcDream.Plugin.Abstractions/Automation.cs +++ b/src/AcDream.Plugin.Abstractions/Automation.cs @@ -53,7 +53,38 @@ public readonly record struct PluginSpellInfo( uint School, string Description, bool IsSelfTargeted, - bool IsBeneficial); + bool IsBeneficial) +{ + /// Retail spell-table classification, projected without policy. + public bool IsDebuff { get; init; } + public bool IsOffensive { get; init; } + public bool IsFellowship { get; init; } + public bool IsUntargeted { get; init; } + /// + /// VTank's spell-facing rule: targeted spells require facing except the + /// authored family range 222..235. + /// + public bool RequiresTurnTo { get; init; } + public bool IsProjectile { get; init; } + public bool IsDamageOverTime { get; init; } + public uint RawFlags { get; init; } + public int SpellType { get; init; } + public uint TargetMask { get; init; } + public float BaseRangeConstant { get; init; } + public float BaseRangeModifier { get; init; } + /// + /// Retail formula component ids in authored order. Plugins can inspect + /// requirements without importing client/Core spell types. + /// + public IReadOnlyList FormulaComponentIds { get; init; } = + Array.Empty(); + /// + /// VTank's spell quality. It is the portal spell difficulty unless its + /// official GameInfoDB override supplies a replacement. + /// + public int? QualityOverride { get; init; } + public int Quality => QualityOverride ?? Difficulty; +} /// One enchantment currently in force on the local player. public readonly record struct PluginActiveEnchantment( @@ -62,18 +93,39 @@ public readonly record struct PluginActiveEnchantment( int Tier, double SecondsRemaining); +/// One immutable entry from retail SpellComponentTable 0x0E00000F. +public readonly record struct PluginSpellComponentInfo( + uint ComponentId, + uint WeenieClassId, + string Name, + double BurnRate, + uint GestureId, + double GestureSpeed, + uint IconId, + uint SortKey, + string Type, + string Word); + /// One of the character's skills, named from the retail skill table. public readonly record struct PluginSkillInfo( uint SkillId, string Name, PluginSkillTraining Training, - uint Current); + uint Current) +{ + /// Unenchanted retail skill level before vitae and spell mods. + public uint Base { get; init; } = Current; +} /// One primary attribute. is 0..5. public readonly record struct PluginAttributeInfo( int Kind, string Name, - uint Current); + uint Current) +{ + /// Unenchanted primary-attribute value. + public uint Base { get; init; } = Current; +} /// Why a cast would or would not be accepted right now. public enum PluginCastGate @@ -93,6 +145,28 @@ public interface ICharacterInfo { bool IsInWorld { get; } + /// + /// Stable in-world character name. Empty when unavailable. Plugins use it + /// for VTank-compatible "By char" profile scoping; it is identity data, + /// not a presentation-owned label. + /// + string Name => string.Empty; + + /// Server-advertised world name used to scope global variables. + string WorldName => string.Empty; + + /// Authenticated account name; expression surfaces expose only its hash. + string AccountName => string.Empty; + + /// Retail roster slot for this character, or -1 when unavailable. + int CharacterIndex => -1; + + /// Current character level. + int Level => 0; + + /// Unused ordinary slots in the main pack. + int MainPackFreeSlots => 0; + /// /// The local player's own object id, or 0 when not in world. Needed to /// target yourself: retail's banes are Item Enchantments whose description @@ -108,6 +182,12 @@ public interface ICharacterInfo uint CurrentMana { get; } uint MaxMana { get; } + /// + /// Retail PropertyInt.SummoningMastery: 0 undef/geomancer, 1 primalist, + /// 2 necromancer, 3 naturalist. + /// + int SummoningMastery => 0; + /// Skills the character has, with training state and current level. IReadOnlyList Skills { get; } @@ -141,18 +221,75 @@ public interface ISpellCatalog /// IReadOnlyList KnownSelfBuffs { get; } + /// + /// Learned direct offensive spells. Debuffs and beneficial spells are + /// excluded; the plugin owns which attack spell to choose. + /// + IReadOnlyList KnownAttackSpells => + Array.Empty(); + + /// + /// Every learned offensive or debuff spell, including untargeted rings, + /// streaks and damage-over-time lines. The host supplies data; the plugin + /// decides which names/families implement its combat policy. + /// + IReadOnlyList KnownCombatSpells => + Array.Empty(); + + /// + /// Whether the character has learned this exact spell id. + /// answers a different question: it can resolve metadata for spells that + /// are not in the character's spellbook, such as a scroll being appraised. + /// + bool IsKnown(uint spellId) => false; + bool TryGet(uint spellId, out PluginSpellInfo info); + + /// Resolve retail's spell-component id, not its inventory WCID. + bool TryGetComponent(uint componentId, out PluginSpellComponentInfo info) + { + info = default; + return false; + } + + /// Seconds remaining for one retail shared cooldown id. + double GetCooldownRemaining(uint cooldownId) => 0d; } /// Writing to the player's chat window. +public readonly record struct PluginChatMessage( + ulong Sequence, + uint SenderObjectId, + int Kind, + string Sender, + string Text, + string ChannelName); + +/// Reading confirmed chat and writing client-local notices. public interface IPluginChat { + /// + /// Ordered transcript messages newer than . + /// The cursor is host-session independent and monotonically increases for + /// the lifetime of this automation surface. VTank uses actual combat lines + /// such as "You cast ... on ..." to confirm item and weapon procs. + /// + IReadOnlyList CaptureMessages(ulong afterSequence) => + Array.Empty(); + /// /// Post a client-local system line, the channel retail uses for the /// client's own notices. It is local to this client: nothing is sent to the /// server and no other player sees it. /// void PostSystemMessage(string text); + + /// + /// Submit text through the client's normal retail chat-command parser. + /// Commands, emotes, tells, and ordinary speech therefore use the same + /// route as text entered in the main chat field. + /// + bool Submit(string text) => false; } /// Casting, with a preflight so a plugin need not guess. @@ -160,6 +297,13 @@ public interface IMagicCommands { bool IsCasting { get; } + /// + /// Last server-completed cast request. Revision changes exactly once when + /// the matching UseDone arrives; zero means the host cannot supply cast + /// receipts. A dispatched request is not reported as success early. + /// + PluginCastCompletion LastCompletion => default; + PluginCastGate EvaluateGate(uint spellId); /// @@ -167,6 +311,13 @@ public interface IMagicCommands /// not whether the spell ultimately lands, which the server decides. /// bool Cast(uint spellId); + + /// Evaluate a cast against an explicit target atomically. + PluginCastGate EvaluateGate(uint spellId, uint targetObjectId) => + PluginCastGate.Refused; + + /// Select and cast on one explicit target in the same host call. + bool Cast(uint spellId, uint targetObjectId) => false; } /// @@ -187,6 +338,52 @@ public interface IAutomationSurface ISpellCatalog Spells { get; } IMagicCommands Magic { get; } IPluginChat Chat { get; } + + /// + /// Target queries and physical-combat attempts. The default keeps plugins + /// compiled against API v1 binary-compatible with hosts that do not yet + /// provide combat automation. + /// + ICombatAutomation Combat => NoOpAutomationSurface.Instance; + + /// Owned equipment reads and confirmed AutoWield attempts. + IEquipmentAutomation Equipment => NoOpAutomationSurface.Instance; + + /// Carried-item reads and canonical use/apply attempts. + IItemAutomation Items => NoOpAutomationSurface.Instance; + + /// External-container discovery and canonical corpse looting. + ILootAutomation Loot => NoOpAutomationSurface.Instance; + + /// Authoritative fellowship vitals for helper spell policy. + IFellowshipAutomation Fellowship => NoOpAutomationSurface.Instance; + + /// Shared confirmed duration-spell observations by target. + IEnchantmentAutomation Enchantments => NoOpAutomationSurface.Instance; + + /// Canonical position reads and command-interpreter movement. + INavigationAutomation Navigation => NoOpAutomationSurface.Instance; + + /// General canonical object discovery and raw property access. + IWorldObjectAutomation Objects => NoOpAutomationSurface.Instance; + + /// Runtime-owned Dereth calendar and day/night projection. + IWorldTimeAutomation WorldTime => NoOpAutomationSurface.Instance; + + /// Account roster and one-shot post-logout character entry. + ILoginAutomation Login => NoOpAutomationSurface.Instance; + + /// Other local acdream clients discovered by the host. + INetworkAutomation Network => NoOpAutomationSurface.Instance; + + /// Explicit VTank-compatible stuck-action recovery. + IRecoveryAutomation Recovery => NoOpAutomationSurface.Instance; + + /// Bounded projectile collision probes over the live world. + IProjectileAutomation Projectiles => NoOpAutomationSurface.Instance; + + /// Canonical retail previous/next selection actions. + ISelectionAutomation Selection => NoOpAutomationSurface.Instance; } /// @@ -194,7 +391,13 @@ public interface IAutomationSurface /// and every command refuses, so a plugin can keep one code path. /// public sealed class NoOpAutomationSurface - : IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands, IPluginChat + : IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands, + IPluginChat, ICombatAutomation + , IEquipmentAutomation, IItemAutomation, ILootAutomation, + IFellowshipAutomation, IEnchantmentAutomation, INavigationAutomation + , IWorldObjectAutomation, IWorldTimeAutomation, ILoginAutomation, + INetworkAutomation, IRecoveryAutomation, IProjectileAutomation + , ISelectionAutomation { public static NoOpAutomationSurface Instance { get; } = new(); @@ -207,11 +410,41 @@ public sealed class NoOpAutomationSurface public ISpellCatalog Spells => this; public IMagicCommands Magic => this; public IPluginChat Chat => this; + public ICombatAutomation Combat => this; + public IEquipmentAutomation Equipment => this; + public IItemAutomation Items => this; + public ILootAutomation Loot => this; + public IFellowshipAutomation Fellowship => this; + public IEnchantmentAutomation Enchantments => this; + public INavigationAutomation Navigation => this; + public IWorldObjectAutomation Objects => this; + public IWorldTimeAutomation WorldTime => this; + public ILoginAutomation Login => this; + public INetworkAutomation Network => this; + public IRecoveryAutomation Recovery => this; + public IProjectileAutomation Projectiles => this; + public ISelectionAutomation Selection => this; public void PostSystemMessage(string text) { } + public bool Submit(string text) => false; + + PluginNavigationSnapshot INavigationAutomation.Snapshot => default; + public bool TryGetObject( + uint objectId, + out PluginNavigationObject value) + { + value = default; + return false; + } + public PluginNavigationCommandStatus SetMovementIntent( + in PluginMovementIntent intent) => + PluginNavigationCommandStatus.Unavailable; + public PluginNavigationCommandStatus ClearMovementIntent() => + PluginNavigationCommandStatus.Unavailable; + public bool IsInWorld => false; public uint ObjectId => 0; public uint CurrentHealth => 0; @@ -220,6 +453,7 @@ public sealed class NoOpAutomationSurface public uint MaxStamina => 0; public uint CurrentMana => 0; public uint MaxMana => 0; + public int SummoningMastery => 0; public IReadOnlyList Skills { get; } = Array.Empty(); public IReadOnlyList Attributes { get; } = @@ -228,6 +462,10 @@ public sealed class NoOpAutomationSurface Array.Empty(); public IReadOnlyList KnownSelfBuffs { get; } = Array.Empty(); + public IReadOnlyList KnownAttackSpells { get; } = + Array.Empty(); + public IReadOnlyList KnownCombatSpells { get; } = + Array.Empty(); public bool TryGetSkill(uint skillId, out PluginSkillInfo skill) { @@ -242,6 +480,64 @@ public sealed class NoOpAutomationSurface } public bool IsCasting => false; + public PluginCastCompletion LastCompletion => default; public PluginCastGate EvaluateGate(uint spellId) => PluginCastGate.Unavailable; public bool Cast(uint spellId) => false; + public PluginCastGate EvaluateGate(uint spellId, uint targetObjectId) => + PluginCastGate.Unavailable; + public bool Cast(uint spellId, uint targetObjectId) => false; + + public PluginCombatSnapshot Snapshot => default; + public IReadOnlyList CaptureHostileTargets( + float maximumDistance) => Array.Empty(); + public PluginCombatCommandResult EnterDefaultMode() => new( + PluginCombatCommandStatus.Unavailable); + bool IEquipmentAutomation.IsAvailable => false; + bool IEquipmentAutomation.IsBusy => false; + public IReadOnlyList CaptureOwnedEquipment() => + Array.Empty(); + public PluginEquipmentCommandResult Equip( + uint objectId, + uint requestedLocation = 0u) => + new(PluginEquipmentCommandStatus.Unavailable); + bool IItemAutomation.IsAvailable => false; + bool IItemAutomation.IsBusy => false; + int IItemAutomation.ActiveOwnedPetCount => 0; + PluginItemUseCompletion IItemAutomation.LastCompletion => default; + PluginItemUseCompletion ILootAutomation.LastItemUseCompletion => default; + PluginInventoryCompletion ILootAutomation.LastInventoryCompletion => default; + PluginAppraisalState ILootAutomation.Appraisal => default; + public IReadOnlyList CaptureOwnedItems() => + Array.Empty(); + public PluginItemCommandResult Use(uint objectId) => + new(PluginItemCommandStatus.Unavailable); + public PluginItemCommandResult Apply(uint objectId, uint targetObjectId) => + new(PluginItemCommandStatus.Unavailable); + public IReadOnlyList CaptureCorpses( + float maximumDistance) => Array.Empty(); + public IReadOnlyList CaptureCurrentContents() => + Array.Empty(); + public PluginItemCommandResult Open(uint containerObjectId) => + new(PluginItemCommandStatus.Unavailable); + public PluginItemCommandResult Identify(uint objectId) => + new(PluginItemCommandStatus.Unavailable); + public PluginItemCommandResult Pickup(uint objectId, bool mainPack = false) => + new(PluginItemCommandStatus.Unavailable); + public bool IsInFellowship => false; + public IReadOnlyList CaptureMembers() => + Array.Empty(); + public IReadOnlyList Capture( + uint targetObjectId) => Array.Empty(); + public bool ReportCast( + uint targetObjectId, + uint spellId, + double durationSeconds) => false; + PluginWorldTimeSnapshot IWorldTimeAutomation.Snapshot => default; + public PluginCombatCommandResult BeginPhysicalAttack( + uint targetObjectId, PluginAttackHeight height, float power) => new( + PluginCombatCommandStatus.Unavailable); + public PluginCombatCommandResult ReleasePhysicalAttack() => new( + PluginCombatCommandStatus.Unavailable); + public PluginCombatCommandResult AbortPhysicalAttack() => new( + PluginCombatCommandStatus.Unavailable); } diff --git a/src/AcDream.Plugin.Abstractions/CombatAutomation.cs b/src/AcDream.Plugin.Abstractions/CombatAutomation.cs new file mode 100644 index 00000000..1c33be77 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/CombatAutomation.cs @@ -0,0 +1,152 @@ +namespace AcDream.Plugin.Abstractions; + +/// Presentation-independent combat mode projected to a plugin. +public enum PluginCombatMode +{ + Unknown = 0, + Peace, + Melee, + Missile, + Magic, +} + +/// Retail's three physical attack heights. +public enum PluginAttackHeight +{ + High = 1, + Medium = 2, + Low = 3, +} + +/// One canonical hostile candidate at the instant it was captured. +public readonly record struct PluginCombatTarget( + uint ObjectId, + string Name, + uint WeenieClassId, + float Distance, + float RelativeAngleDegrees, + bool IsHealthKnown, + float HealthFraction) +{ + /// Retail PropertyInt CreatureType (2), or zero when unknown. + public int SpeciesId { get; init; } + + /// Retail creature-enum display name used by VTank's species variable. + public string SpeciesName { get; init; } = string.Empty; + + /// Spawn/appraisal maximum HP, or zero until the host knows it. + public int MaximumHealth { get; init; } + + /// + /// VTank's dynamic hasshield value: true when the target currently has an + /// equipped object whose object class is Armor. + /// + public bool HasShield { get; init; } + public ushort Incarnation { get; init; } + + /// Monotonic revision of the last server health update. + public long HealthRevision { get; init; } + + /// + /// Seconds since the last server health update at capture time, or + /// positive infinity when health has never been reported. + /// + public double SecondsSinceHealthUpdate { get; init; } = + double.PositiveInfinity; +} + +/// The canonical local combat/attack state visible to a plugin. +public readonly record struct PluginCombatSnapshot( + uint SelectedObjectId, + PluginCombatMode Mode, + PluginAttackHeight AttackHeight, + float DesiredPower, + float PowerBarLevel, + bool BuildInProgress, + bool RequestInProgress, + bool ServerResponsePending, + bool RepeatAttackInProgress) +{ + /// Revision of the last physical AttackDone receipt. + public long CompletionRevision { get; init; } + public uint CompletionSequence { get; init; } + public uint CompletionWeenieError { get; init; } +} + +/// Why an automation combat command did or did not proceed. +public enum PluginCombatCommandStatus +{ + Unavailable = 0, + InvalidTarget, + WrongMode, + Busy, + AlreadyReady, + ModeChangeSent, + Started, + Released, + Stopped, + Refused, +} + +public readonly record struct PluginCombatCommandResult( + PluginCombatCommandStatus Status, + string? Notice = null) +{ + public bool Accepted => Status is + PluginCombatCommandStatus.AlreadyReady + or PluginCombatCommandStatus.ModeChangeSent + or PluginCombatCommandStatus.Started + or PluginCombatCommandStatus.Released + or PluginCombatCommandStatus.Stopped; +} + +/// +/// Host combat primitives. The host owns no macro policy: it projects the +/// canonical candidates/state and attempts the exact retail input operations +/// MossTank asks for. +/// +public interface ICombatAutomation +{ + PluginCombatSnapshot Snapshot { get; } + + /// + /// Capture currently valid hostile creatures no farther than + /// meters from the local player. + /// Snapshot semantics: the returned list is never mutated in place. + /// + IReadOnlyList CaptureHostileTargets(float maximumDistance); + + /// + /// Enter the combat mode implied by currently equipped items. If already + /// in any combat mode this reports . + /// + PluginCombatCommandResult EnterDefaultMode(); + + /// + /// Request one explicit retail combat mode. VTank needs this after + /// selecting a caster, melee proc weapon, or grenade; the host still owns + /// and sends the canonical mode transition. + /// + PluginCombatCommandResult EnterMode(PluginCombatMode mode) => + new(PluginCombatCommandStatus.Unavailable); + + /// + /// Select , set the desired power and + /// press the retail attack-height input. Release is a separate command so + /// a plugin can wait for the real power bar. + /// + PluginCombatCommandResult BeginPhysicalAttack( + uint targetObjectId, + PluginAttackHeight height, + float power); + + PluginCombatCommandResult ReleasePhysicalAttack(); + PluginCombatCommandResult AbortPhysicalAttack(); + + /// + /// Retire a client-side ghost through the host's canonical entity teardown + /// path. This never sends a server delete and must reject the local player. + /// + PluginCombatCommandResult DismissGhostTarget(uint targetObjectId) => + new(PluginCombatCommandStatus.Unavailable); +} diff --git a/src/AcDream.Plugin.Abstractions/EnchantmentAutomation.cs b/src/AcDream.Plugin.Abstractions/EnchantmentAutomation.cs new file mode 100644 index 00000000..778d7bff --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/EnchantmentAutomation.cs @@ -0,0 +1,35 @@ +namespace AcDream.Plugin.Abstractions; + +/// +/// One duration spell observed on a world object. This is a timer ledger, not +/// an authoritative server enchantment registry: retail VTank built the same +/// view from confirmed local casts and casts reported by cooperating plugins. +/// +public readonly record struct PluginTrackedEnchantment( + uint TargetObjectId, + uint SpellId, + uint Family, + int Quality, + bool IsUntargeted, + double SecondsRemaining); + +/// +/// Shared per-client duration-spell ledger. The host records successful local +/// casts automatically. Plugins that perform casts outside the host's normal +/// command surface can report their confirmed result, matching VTank's public +/// LogSpellCast(target, spell, duration) capability. +/// +public interface IEnchantmentAutomation +{ + IReadOnlyList Capture(uint targetObjectId) => + Array.Empty(); + + /// + /// Report a confirmed duration spell. Dispatch attempts must not be + /// reported; is the effective duration. + /// + bool ReportCast( + uint targetObjectId, + uint spellId, + double durationSeconds) => false; +} diff --git a/src/AcDream.Plugin.Abstractions/EquipmentAutomation.cs b/src/AcDream.Plugin.Abstractions/EquipmentAutomation.cs new file mode 100644 index 00000000..0b1928f9 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/EquipmentAutomation.cs @@ -0,0 +1,64 @@ +namespace AcDream.Plugin.Abstractions; + +/// One owned item that can participate in VTank equipment policy. +public readonly record struct PluginEquipmentItem( + uint ObjectId, + string Name, + uint ItemType, + uint ValidLocations, + uint EquippedLocation, + uint ContainerObjectId, + uint WielderObjectId, + byte CombatUse, + int DamageType, + int WeaponSkill, + int Damage, + double DamageVariance) +{ + public bool IsEquipped => EquippedLocation != 0u; + /// Retail AMMO_TYPE bit from PublicWeenieDesc. + public uint AmmoType { get; init; } + public int StackSize { get; init; } = 1; + public int WeaponType { get; init; } +} + +public enum PluginEquipmentCommandStatus +{ + Unavailable = 0, + InvalidItem, + Busy, + AlreadyEquipped, + Started, + Refused, +} + +public readonly record struct PluginEquipmentCommandResult( + PluginEquipmentCommandStatus Status, + string? Notice = null) +{ + public bool Accepted => Status is + PluginEquipmentCommandStatus.AlreadyEquipped + or PluginEquipmentCommandStatus.Started; +} + +/// +/// Borrowed inventory equipment view and one request through the client's +/// canonical confirmed AutoWield transaction. +/// +public interface IEquipmentAutomation +{ + bool IsAvailable => false; + bool IsBusy => false; + + IReadOnlyList CaptureOwnedEquipment() => + Array.Empty(); + + /// + /// Zero asks retail AutoWield to choose; otherwise this is the exact + /// retail INVENTORY_LOC bit requested by a profile. + /// + PluginEquipmentCommandResult Equip( + uint objectId, + uint requestedLocation = 0u) => + new(PluginEquipmentCommandStatus.Unavailable); +} diff --git a/src/AcDream.Plugin.Abstractions/FellowshipAutomation.cs b/src/AcDream.Plugin.Abstractions/FellowshipAutomation.cs new file mode 100644 index 00000000..767120e3 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/FellowshipAutomation.cs @@ -0,0 +1,72 @@ +namespace AcDream.Plugin.Abstractions; + +/// One authoritative fellowship-roster entry plus live range. +public readonly record struct PluginFellowMember( + uint ObjectId, + string Name, + uint CurrentHealth, + uint MaxHealth, + uint CurrentStamina, + uint MaxStamina, + uint CurrentMana, + uint MaxMana, + float Distance) +{ + /// + /// The member's authoritative fellowship Share Loot bit. VTank permits + /// immediate corpse access for a fellow only when this bit is set; a + /// non-sharing fellow's corpse remains protected for retail's 100-second + /// public-loot interval. + /// + public bool ShareLoot { get; init; } +} + +public enum PluginFellowshipCommandStatus +{ + Unavailable = 0, + Accepted, + Rejected, +} + +public readonly record struct PluginFellowshipCommandResult( + PluginFellowshipCommandStatus Status) +{ + public bool Accepted => Status == PluginFellowshipCommandStatus.Accepted; +} + +/// +/// Group state and generation-gated retail fellowship commands. Recruitment, +/// waiting lists, voting, and social policy remain plugin behavior; the host +/// only exposes the canonical wire operations already used by the retail UI. +/// +public interface IFellowshipAutomation +{ + bool IsInFellowship => false; + string Name => string.Empty; + uint LeaderObjectId => 0u; + bool IsOpen => false; + bool IsLocked => false; + int MemberCount => 0; + IReadOnlyList CaptureMembers() => + Array.Empty(); + + /// + /// Complete authoritative roster in server insertion order, including the + /// local player. Use for helper/healer policy + /// that intentionally excludes self. + /// + IReadOnlyList CaptureRoster() => CaptureMembers(); + + PluginFellowshipCommandResult Create(string name, bool shareExperience) => + new(PluginFellowshipCommandStatus.Unavailable); + PluginFellowshipCommandResult Recruit(uint targetObjectId) => + new(PluginFellowshipCommandStatus.Unavailable); + PluginFellowshipCommandResult Dismiss(uint targetObjectId) => + new(PluginFellowshipCommandStatus.Unavailable); + PluginFellowshipCommandResult Quit(bool disband) => + new(PluginFellowshipCommandStatus.Unavailable); + PluginFellowshipCommandResult AssignLeader(uint targetObjectId) => + new(PluginFellowshipCommandStatus.Unavailable); + PluginFellowshipCommandResult SetOpen(bool isOpen) => + new(PluginFellowshipCommandStatus.Unavailable); +} diff --git a/src/AcDream.Plugin.Abstractions/IPluginHost.cs b/src/AcDream.Plugin.Abstractions/IPluginHost.cs index 570ab128..5b030725 100644 --- a/src/AcDream.Plugin.Abstractions/IPluginHost.cs +++ b/src/AcDream.Plugin.Abstractions/IPluginHost.cs @@ -20,6 +20,19 @@ public interface IPluginHost IEvents Events { get; } ISelectionService Selection { get; } IUiRegistry Ui { get; } + /// + /// Locally handled slash/at commands. Hosts without command routing expose + /// an inert registry so an API-v1 plugin can retain one code path. + /// + IPluginCommandRegistry Commands => NoOpPluginCommandRegistry.Instance; + /// + /// Durable storage scoped by the host to this plugin's manifest id. + /// No-window/test hosts may explicitly expose the inert implementation. + /// + IPluginStorage Storage => NoOpPluginStorage.Instance; + /// Unload-safe external VTank-style loot classifiers. + IPluginLootClassifierRegistry LootClassifiers => + NoOpPluginLootClassifierRegistry.Instance; /// /// Character reads, spell data and casting. Hosts with no live session diff --git a/src/AcDream.Plugin.Abstractions/IPluginStorage.cs b/src/AcDream.Plugin.Abstractions/IPluginStorage.cs new file mode 100644 index 00000000..aa77b067 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/IPluginStorage.cs @@ -0,0 +1,22 @@ +namespace AcDream.Plugin.Abstractions; + +/// +/// Per-plugin durable text storage. The host scopes keys to the authenticated +/// manifest id, so a plugin cannot collide with another plugin's profile. +/// +public interface IPluginStorage +{ + bool IsAvailable => false; + string? ReadText(string key) => null; + /// Relative file keys beneath one relative prefix. + IReadOnlyList List(string prefix) => Array.Empty(); + void WriteText(string key, string content) => + throw new NotSupportedException("Plugin storage is unavailable."); + bool Delete(string key) => false; +} + +public sealed class NoOpPluginStorage : IPluginStorage +{ + public static NoOpPluginStorage Instance { get; } = new(); + private NoOpPluginStorage() { } +} diff --git a/src/AcDream.Plugin.Abstractions/IUiRegistry.cs b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs index ca587dcf..900971a3 100644 --- a/src/AcDream.Plugin.Abstractions/IUiRegistry.cs +++ b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs @@ -1,5 +1,47 @@ namespace AcDream.Plugin.Abstractions; +/// +/// Stable, presentation-neutral description of one top-level plugin window. +/// The graphical host uses this metadata for its plugin sidepanel and retained +/// window registry; no App/UI type crosses the plugin boundary. +/// +/// +/// Stable id within the owning plugin. It is part of the persisted window-layout +/// key, so it must not be localized or changed between releases. +/// +/// User-facing window title. +public sealed record PluginPanelDescriptor(string WindowId, string Title) +{ + /// + /// Optional one-to-three-character fallback drawn in the sidepanel button + /// when no DAT icon is supplied. The host derives initials from + /// when this is empty. + /// + public string? IconText { get; init; } + + /// + /// Optional installed-client RenderSurface DID. Zero asks the host to draw + /// instead. Plugins never receive the resulting GPU + /// resource and remain BCL-only. + /// + public uint IconSurfaceId { get; init; } + + /// + /// Initial visibility used only when no per-character persisted layout is + /// available. Hiding the window never disables the plugin. + /// + public bool StartVisible { get; init; } = true; + + /// Whether this window receives a button in the shared sidepanel. + public bool ShowInSidePanel { get; init; } = true; +} + +/// +/// Host-authenticated plugin identity attached to registrations by the scoped +/// plugin lifetime. Plugins cannot choose or spoof this value. +/// +public readonly record struct PluginUiOwner(string Id, string DisplayName); + /// /// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) + /// a binding object exposing the data properties the markup binds to, and @@ -13,6 +55,52 @@ public interface IUiRegistry /// Absolute path to the plugin's panel markup file. /// Object whose properties the markup's {Bindings} resolve against. void AddMarkupPanel(string markupPath, object binding); + + /// + /// Registers a first-class plugin window. The host keeps the plugin lifetime + /// independent from the window's visible/minimized state. + /// + /// + /// Defaulting to the API-v1 method keeps older/custom hosts source-compatible; + /// acdream's graphical scoped host overrides this route and preserves all + /// descriptor metadata. + /// + void AddPanel( + PluginPanelDescriptor descriptor, + string markupPath, + object binding) + => AddMarkupPanel(markupPath, binding); + + /// + /// Registers a window whose lifetime may be ended independently while the + /// plugin keeps running. Disposing the token removes the retained window + /// and its sidepanel entry. + /// + IDisposable RegisterPanel( + PluginPanelDescriptor descriptor, + string markupPath, + object binding) + { + AddPanel(descriptor, markupPath, binding); + return NoOpUiRegistration.Instance; + } + + /// + /// Registers an independently removable window from in-memory KSML. This + /// is the BCL-only seam used by VTank-compatible Meta Create View actions; + /// plugins do not need to create temporary files or import App types. + /// + IDisposable RegisterPanelContent( + PluginPanelDescriptor descriptor, + string markupContent, + object binding) => NoOpUiRegistration.Instance; + + /// Queries this plugin's own registered view by title or stable id. + bool ViewExists(string viewName) => false; + bool IsViewVisible(string viewName) => false; + bool ControlExists(string viewName, string controlName) => false; + bool SetControlLabel(string viewName, string controlName, string label) => false; + bool SetControlVisible(string viewName, string controlName, bool visible) => false; } /// @@ -25,6 +113,55 @@ public interface IUiRegistry public interface IScopedUiRegistry : IUiRegistry { IDisposable RegisterMarkupPanel(string markupPath, object binding); + + /// + /// Host-only scoped registration carrying the manifest-derived owner. + /// Disposal removes both the retained window and its sidepanel entry. + /// + IDisposable RegisterPanel( + PluginUiOwner owner, + PluginPanelDescriptor descriptor, + string markupPath, + object binding) + => RegisterMarkupPanel(markupPath, binding); + + /// Host-owned registration for in-memory plugin markup. + IDisposable RegisterPanelContent( + PluginUiOwner owner, + PluginPanelDescriptor descriptor, + string markupContent, + object binding) => NoOpUiRegistration.Instance; + + bool ViewExists(PluginUiOwner owner, string viewName) => false; + bool IsViewVisible(PluginUiOwner owner, string viewName) => false; + bool ControlExists( + PluginUiOwner owner, + string viewName, + string controlName) => false; + bool SetControlLabel( + PluginUiOwner owner, + string viewName, + string controlName, + string label) => false; + bool SetControlVisible( + PluginUiOwner owner, + string viewName, + string controlName, + bool visible) => false; +} + +/// Shared empty registration returned by UI-less/legacy hosts. +public sealed class NoOpUiRegistration : IDisposable +{ + public static NoOpUiRegistration Instance { get; } = new(); + + private NoOpUiRegistration() + { + } + + public void Dispose() + { + } } /// @@ -44,9 +181,38 @@ public sealed class NoOpUiRegistry : IScopedUiRegistry { } + public void AddPanel( + PluginPanelDescriptor descriptor, + string markupPath, + object binding) + { + } + + public IDisposable RegisterPanel( + PluginPanelDescriptor descriptor, + string markupPath, + object binding) => NoOpUiRegistration.Instance; + + public IDisposable RegisterPanelContent( + PluginPanelDescriptor descriptor, + string markupContent, + object binding) => NoOpUiRegistration.Instance; + public IDisposable RegisterMarkupPanel(string markupPath, object binding) => NoOpRegistration.Instance; + public IDisposable RegisterPanel( + PluginUiOwner owner, + PluginPanelDescriptor descriptor, + string markupPath, + object binding) => NoOpRegistration.Instance; + + public IDisposable RegisterPanelContent( + PluginUiOwner owner, + PluginPanelDescriptor descriptor, + string markupContent, + object binding) => NoOpUiRegistration.Instance; + private sealed class NoOpRegistration : IDisposable { internal static NoOpRegistration Instance { get; } = new(); diff --git a/src/AcDream.Plugin.Abstractions/ItemAutomation.cs b/src/AcDream.Plugin.Abstractions/ItemAutomation.cs new file mode 100644 index 00000000..4709a50c --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/ItemAutomation.cs @@ -0,0 +1,245 @@ +namespace AcDream.Plugin.Abstractions; + +/// +/// One ordered VTClassic-compatible subpalette sample from an object's model +/// description. RGB is sampled at retail/VTank's representative index. +/// +public readonly record struct PluginPaletteInfo( + uint PaletteId, + byte Offset, + byte Length, + byte Red, + byte Green, + byte Blue); + +/// +/// One carried item from the character's canonical inventory object table. +/// The deliberately raw retail ids let general plugins classify new server +/// content without taking a dependency on acdream's Core enums. +/// +public readonly record struct PluginInventoryItem( + uint ObjectId, + uint WeenieClassId, + string Name, + uint ItemType, + uint ContainerObjectId, + uint WielderObjectId, + uint ValidLocations, + uint EquippedLocation, + uint Useability, + uint TargetType, + uint PublicFlags, + int StackSize, + int Structure, + int MaximumStructure, + uint SpellId, + int PetClass, + int SummoningMastery, + uint ProcSpellId, + bool ProcSpellSelfTargeted, + double ProcSpellRate, + int WeaponSkill, + int DamageType, + int Damage, + double DamageVariance, + int UseRequiresSkill, + int UseRequiresSkillLevel, + int UseRequiresSkillSpecialized) +{ + public bool IsEquipped => EquippedLocation != 0u; + public bool IsPetDevice => PetClass != 0; + public bool HasCastOnStrike => ProcSpellId != 0u && ProcSpellRate > 0d; + public int CombatUse { get; init; } + public int ItemSpellcraft { get; init; } + public int WieldRequirements { get; init; } + public int WieldSkillType { get; init; } + public int WieldDifficulty { get; init; } + public int AttackType { get; init; } + public int WeaponType { get; init; } + /// + /// Retail PropertyInt.BoosterEnum: current Health/Stamina/Mana are + /// 2/4/6. VTank uses this to classify both kits and food without relying + /// on localized item names. + /// + public int BoosterVital { get; init; } + public int BoostValue { get; init; } + public double HealKitModifier { get; init; } + public IReadOnlyList AppraisedSpellIds { get; init; } = + Array.Empty(); + public int GearDamage { get; init; } + public int GearDamageResistance { get; init; } + public int GearCriticalChance { get; init; } + public int GearCriticalResistance { get; init; } + public int GearCriticalDamage { get; init; } + public int GearCriticalDamageResistance { get; init; } + /// Retail PublicWeenieDesc maximum stack size. + public int MaximumStackSize { get; init; } = 1; + /// Current zero-based slot inside . + public int ContainerSlot { get; init; } = -1; + /// Number of ordinary item slots when this object is a container. + public int ItemsCapacity { get; init; } + /// Number of nested-container slots when this object is a container. + public int ContainersCapacity { get; init; } + /// Current total burden of this object or stack. + public int Burden { get; init; } + public int Value { get; init; } + public int ItemCurrentMana { get; init; } + public int ItemMaximumMana { get; init; } + public float Workmanship { get; init; } + public uint MaterialType { get; init; } + /// Virindi/Decal's stable object class, not ItemType flags. + public PluginObjectClass ObjectClass { get; init; } + public IReadOnlyList Palettes { get; init; } = + Array.Empty(); +} + +/// +/// On-demand copy of an item's raw retail property tables. Loot and expression +/// engines can understand future server content without making every ordinary +/// inventory scan clone seven dictionaries per item. +/// +public readonly record struct PluginItemProperties( + IReadOnlyDictionary Ints, + IReadOnlyDictionary Int64s, + IReadOnlyDictionary Bools, + IReadOnlyDictionary Floats, + IReadOnlyDictionary Strings, + IReadOnlyDictionary DataIds, + IReadOnlyDictionary InstanceIds); + +/// One server UseDone for a plugin-issued item action. +public readonly record struct PluginItemUseCompletion( + long Revision, + uint SourceObjectId, + uint TargetObjectId, + uint WeenieError) +{ + public bool IsSuccess => Revision != 0 && WeenieError == 0u; +} + +public enum PluginItemCommandStatus +{ + Unavailable = 0, + InvalidItem, + InvalidTarget, + Busy, + Started, + Refused, +} + +public readonly record struct PluginItemCommandResult( + PluginItemCommandStatus Status, + string? Notice = null) +{ + public bool Accepted => Status == PluginItemCommandStatus.Started; +} + +/// The retail inventory request that produced a completion receipt. +public enum PluginInventoryCommandKind +{ + Unknown = 0, + Pickup, + PutInContainer, + SplitToContainer, + Merge, + Move, + DropToWorld, + SplitToWorld, + Wield, + Give, +} + +/// +/// Authoritative completion of one plugin or UI inventory transaction. A +/// started command is not success until this revision advances for its source. +/// +public readonly record struct PluginInventoryCompletion( + long Revision, + PluginInventoryCommandKind Kind, + uint SourceObjectId, + uint WeenieError) +{ + public bool IsSuccess => Revision != 0 && WeenieError == 0u; +} + +/// +/// Borrowed inventory view and item actions through the client's one retail +/// item-interaction transaction. A successful command means only that the +/// request started; is the server result. +/// +public interface IItemAutomation +{ + bool IsAvailable => false; + bool IsBusy => false; + int ActiveOwnedPetCount => 0; + uint ActiveVendorObjectId => 0u; + PluginItemUseCompletion LastCompletion => default; + PluginInventoryCompletion LastInventoryCompletion => default; + + IReadOnlyList CaptureOwnedItems() => + Array.Empty(); + + bool TryCaptureProperties( + uint objectId, + out PluginItemProperties properties) + { + properties = default; + return false; + } + + PluginItemCommandResult Use(uint objectId) => + new(PluginItemCommandStatus.Unavailable); + + PluginItemCommandResult Apply(uint objectId, uint targetObjectId) => + new(PluginItemCommandStatus.Unavailable); + + /// + /// Move all or an exact partial quantity into a carried container. An + /// amount of zero means the whole current stack. + /// + PluginItemCommandResult MoveToContainer( + uint objectId, + uint containerObjectId, + uint amount = 0u, + int placement = 0) => + new(PluginItemCommandStatus.Unavailable); + + /// + /// Merge up to units from source into target. + /// Zero means as much as retail permits. + /// + PluginItemCommandResult Merge( + uint sourceObjectId, + uint targetObjectId, + uint amount = 0u) => + new(PluginItemCommandStatus.Unavailable); + + /// Drop all or an exact partial stack on the ground. + PluginItemCommandResult Drop(uint objectId, uint amount = 0u) => + new(PluginItemCommandStatus.Unavailable); + + /// Give all or an exact partial stack to a world target. + PluginItemCommandResult Give( + uint objectId, + uint targetObjectId, + uint amount = 0u) => + new(PluginItemCommandStatus.Unavailable); + + /// + /// Salvage one or more owned items with an owned tinkering/salvage tool. + /// The command is the retail 0x027D operation; source-item removal is the + /// authoritative completion signal until a host projects the 0x02B4 + /// material-result details. + /// + PluginItemCommandResult Salvage( + uint toolObjectId, + IReadOnlyList itemObjectIds) => + new(PluginItemCommandStatus.Unavailable); + + /// + /// Sell an owned item through the currently-open authoritative vendor. + /// Zero amount means the complete current stack. + /// + PluginItemCommandResult Sell(uint objectId, uint amount = 0u) => + new(PluginItemCommandStatus.Unavailable); +} diff --git a/src/AcDream.Plugin.Abstractions/LoginAutomation.cs b/src/AcDream.Plugin.Abstractions/LoginAutomation.cs new file mode 100644 index 00000000..09ee6ff1 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/LoginAutomation.cs @@ -0,0 +1,25 @@ +namespace AcDream.Plugin.Abstractions; + +/// One character in the account's authoritative login roster. +public readonly record struct PluginLoginCharacter( + uint ObjectId, + string Name, + int ActiveIndex, + bool IsPendingDelete); + +/// +/// Account-roster and one-shot next-login control. The host owns the login +/// transaction; plugins only select or clear the character to enter when the +/// current character returns to character selection. +/// +public interface ILoginAutomation +{ + bool IsAvailable => false; + uint NextLoginObjectId => 0u; + + IReadOnlyList CaptureRoster() => + Array.Empty(); + + bool SetNextLogin(uint characterObjectId) => false; + bool ClearNextLogin() => false; +} diff --git a/src/AcDream.Plugin.Abstractions/LootAutomation.cs b/src/AcDream.Plugin.Abstractions/LootAutomation.cs new file mode 100644 index 00000000..adad2cc3 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/LootAutomation.cs @@ -0,0 +1,69 @@ +namespace AcDream.Plugin.Abstractions; + +/// +/// One live external container that an automation plugin may approach and use. +/// The host classifies corpses; plugins decide whether and when to loot them. +/// +public readonly record struct PluginLootContainer( + uint ObjectId, + uint WeenieClassId, + string Name, + float Distance, + bool HasBeenOpened, + bool IsRequested, + bool IsCurrent) +{ + public string LongDescription { get; init; } = string.Empty; + public bool IsGeneratedRare { get; init; } + public bool IsIdentified { get; init; } +} + +public readonly record struct PluginAppraisalState( + long Revision, + uint AwaitingObjectId, + uint CurrentObjectId); + +/// +/// Read-only corpse/container discovery plus canonical open and pickup commands. +/// Successful commands mean the request started; completion is reported through +/// or . +/// +public interface ILootAutomation +{ + bool IsAvailable => false; + bool IsBusy => false; + uint RequestedContainerId => 0u; + uint CurrentContainerId => 0u; + PluginItemUseCompletion LastItemUseCompletion => default; + PluginInventoryCompletion LastInventoryCompletion => default; + PluginAppraisalState Appraisal => default; + + IReadOnlyList CaptureCorpses(float maximumDistance) => + Array.Empty(); + + /// + /// Captures the complete currently viewed external-container tree. Entries + /// are ordered depth-first in retail container-slot order. + /// + IReadOnlyList CaptureCurrentContents() => + Array.Empty(); + + bool TryCaptureProperties( + uint objectId, + out PluginItemProperties properties) + { + properties = default; + return false; + } + + PluginItemCommandResult Open(uint containerObjectId) => + new(PluginItemCommandStatus.Unavailable); + + PluginItemCommandResult Identify(uint objectId) => + new(PluginItemCommandStatus.Unavailable); + + PluginItemCommandResult Pickup( + uint objectId, + bool mainPack = false) => + new(PluginItemCommandStatus.Unavailable); +} diff --git a/src/AcDream.Plugin.Abstractions/LootClassifierPlugins.cs b/src/AcDream.Plugin.Abstractions/LootClassifierPlugins.cs new file mode 100644 index 00000000..312a923a --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/LootClassifierPlugins.cs @@ -0,0 +1,93 @@ +namespace AcDream.Plugin.Abstractions; + +/// VTank's public loot-plugin action vocabulary. +public enum PluginLootAction +{ + NoLoot = 0, + Keep = 1, + Salvage = 2, + Sell = 3, + Read = 4, + User1 = 5, + User2 = 6, + User3 = 7, + User4 = 8, + User5 = 9, + KeepUpTo = 10, +} + +public readonly record struct PluginLootClassificationContext( + PluginInventoryItem Item, + PluginItemProperties Properties, + IReadOnlyList OwnedItems); + +/// A classifier's detached decision. Matched=false means no rule. +public readonly record struct PluginLootClassification( + bool Matched, + PluginLootAction Action, + string RuleName = "", + int Priority = 0, + int KeepCount = 0); + +/// +/// A classified item after the server-confirmed move into owned inventory. +/// This is VTank's custom-action item ledger boundary. +/// +public readonly record struct PluginLootedItem( + PluginInventoryItem Item, + PluginLootAction Action); + +public interface IPluginLootClassifier +{ + PluginLootClassification Classify( + in PluginLootClassificationContext context); + + void OnLooted(in PluginLootedItem item) { } + + void OnItemRemoved(uint objectId) { } +} + +public readonly record struct PluginLootClassifierInfo( + string Id, + string DisplayName); + +/// +/// Machine-local, in-process classifier exchange. Registration lifetime is +/// scoped to the owning plugin by the host; callers never retain an unloaded +/// plugin's classifier. +/// +public interface IPluginLootClassifierRegistry +{ + IReadOnlyList Available => + Array.Empty(); + + IDisposable Register( + string classifierId, + string displayName, + IPluginLootClassifier classifier) => + throw new NotSupportedException("Loot classifiers are unavailable."); + + bool TryClassify( + string classifierId, + in PluginLootClassificationContext context, + out PluginLootClassification classification) + { + classification = default; + return false; + } + + bool TryNotifyLooted( + string classifierId, + in PluginLootedItem item) => false; + + bool TryNotifyItemRemoved( + string classifierId, + uint objectId) => false; +} + +public sealed class NoOpPluginLootClassifierRegistry + : IPluginLootClassifierRegistry +{ + public static NoOpPluginLootClassifierRegistry Instance { get; } = new(); + private NoOpPluginLootClassifierRegistry() { } +} diff --git a/src/AcDream.Plugin.Abstractions/MagicAutomation.cs b/src/AcDream.Plugin.Abstractions/MagicAutomation.cs new file mode 100644 index 00000000..83027534 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/MagicAutomation.cs @@ -0,0 +1,11 @@ +namespace AcDream.Plugin.Abstractions; + +/// One authoritative completion of a spell request. +public readonly record struct PluginCastCompletion( + long Revision, + uint SpellId, + uint TargetObjectId, + uint WeenieError) +{ + public bool IsSuccess => Revision != 0 && WeenieError == 0u; +} diff --git a/src/AcDream.Plugin.Abstractions/NavigationAutomation.cs b/src/AcDream.Plugin.Abstractions/NavigationAutomation.cs new file mode 100644 index 00000000..3a3a37c8 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/NavigationAutomation.cs @@ -0,0 +1,115 @@ +namespace AcDream.Plugin.Abstractions; + +/// +/// Stable Asheron's Call map coordinate. East/west and north/south use the +/// familiar in-game coordinate scale (for example 33.5S, 72.8E); elevation is +/// expressed in metres. The cell id is retained because indoor coordinates do +/// not have a meaningful outdoor compass label. +/// +public readonly record struct PluginNavigationPosition( + uint CellId, + double EastWest, + double NorthSouth, + double Elevation, + float HeadingDegrees, + bool IsOutdoor) +{ + public double HorizontalDistanceMeters(in PluginNavigationPosition other) + { + double dx = EastWest - other.EastWest; + double dy = NorthSouth - other.NorthSouth; + return Math.Sqrt(dx * dx + dy * dy) * 240d; + } +} + +/// One live object's canonical identity, name, and position. +public readonly record struct PluginNavigationObject( + uint ObjectId, + string Name, + PluginNavigationPosition Position) +{ + public bool IsDoor { get; init; } + public bool IsOpen { get; init; } + public bool IsLocked { get; init; } + public bool HasLockState { get; init; } + public int LockDifficulty { get; init; } +} + +/// The local movement state sampled atomically by a plugin tick. +public readonly record struct PluginNavigationSnapshot( + bool IsAvailable, + bool IsPortalSpace, + uint LocalObjectId, + PluginNavigationPosition Position, + bool IsMoving, + bool IsAirborne) +{ + /// + /// Last position accepted from the server for the local player. Ordinary + /// point navigation uses the live physics position; VTank checkpoints use + /// this acknowledgement so client prediction cannot advance the route. + /// + public PluginNavigationPosition ConfirmedPosition { get; init; } + public ulong ConfirmedPositionRevision { get; init; } +} + +/// +/// Semantic movement levels. They are applied through the same Runtime-owned +/// command-interpreter input state as the keyboard; no plugin-only physics or +/// movement model exists. +/// +public readonly record struct PluginMovementIntent( + bool Forward = false, + bool Backward = false, + bool StrafeLeft = false, + bool StrafeRight = false, + bool TurnLeft = false, + bool TurnRight = false, + bool Run = true, + bool Jump = false); + +public enum PluginNavigationCommandStatus +{ + Unavailable = 0, + Accepted, + Rejected, +} + +/// +/// Host navigation primitives. Route sequencing, path policy, following, and +/// waypoint behavior belong to the plugin (as they did in VTank); the host +/// exposes only canonical positions and command-interpreter movement. +/// +public interface INavigationAutomation +{ + PluginNavigationSnapshot Snapshot { get; } + + bool TryGetObject(uint objectId, out PluginNavigationObject value); + + /// + /// Reacquire a world object whose session-scoped id changed, choosing the + /// nearest exact-name match to a saved route position. VTank uses this for + /// its Portal2 and UseNPC waypoint records instead of trusting a stale id. + /// + bool TryFindObject( + string name, + in PluginNavigationPosition near, + double maximumDistanceMeters, + out PluginNavigationObject value) + { + value = default; + return false; + } + + /// + /// Detached live world-object projection used by plugin-owned proximity + /// policies such as VTank's door opener. Hosts may return an empty list. + /// + IReadOnlyList CaptureObjects() => + Array.Empty(); + + PluginNavigationCommandStatus SetMovementIntent( + in PluginMovementIntent intent); + + PluginNavigationCommandStatus ClearMovementIntent(); +} diff --git a/src/AcDream.Plugin.Abstractions/NetworkAutomation.cs b/src/AcDream.Plugin.Abstractions/NetworkAutomation.cs new file mode 100644 index 00000000..e6628170 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/NetworkAutomation.cs @@ -0,0 +1,28 @@ +namespace AcDream.Plugin.Abstractions; + +/// +/// One other live acdream client discovered by the host's local peer service. +/// The shape mirrors UtilityBelt's ClientData expression contract. +/// +public readonly record struct PluginNetworkClient( + uint ClientId, + uint PlayerId, + string Name, + string WorldName, + PluginNavigationPosition Position, + IReadOnlyList Tags, + uint CurrentHealth, + uint CurrentMana, + uint CurrentStamina, + uint MaxHealth, + uint MaxMana, + uint MaxStamina, + float Heading); + +/// Read-only discovery of other local acdream client processes. +public interface INetworkAutomation +{ + bool IsAvailable => false; + IReadOnlyList CaptureClients() => + Array.Empty(); +} diff --git a/src/AcDream.Plugin.Abstractions/PluginCommands.cs b/src/AcDream.Plugin.Abstractions/PluginCommands.cs new file mode 100644 index 00000000..85abee36 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/PluginCommands.cs @@ -0,0 +1,48 @@ +namespace AcDream.Plugin.Abstractions; + +/// One locally handled slash/at command submitted by the player. +public readonly record struct PluginCommand( + string Verb, + string Arguments, + string RawText); + +/// +/// Process-local command registration for gameplay plugins. Registered verbs +/// run before an unknown command is sent to the game server, so plugin commands +/// work from typed chat, launcher login commands, and other plugins' normal +/// chat-submit path. +/// +public interface IPluginCommandRegistry +{ + /// + /// Register one bare verb (for example vt, without a leading slash). + /// Matching is case-insensitive and accepts both retail command prefixes. + /// The returned lease removes only this exact registration. + /// + IDisposable Register(string verb, Action handler); +} + +/// Inert command surface for hosts that cannot route local commands. +public sealed class NoOpPluginCommandRegistry : IPluginCommandRegistry +{ + public static NoOpPluginCommandRegistry Instance { get; } = new(); + + private NoOpPluginCommandRegistry() + { + } + + public IDisposable Register(string verb, Action handler) + { + ArgumentException.ThrowIfNullOrWhiteSpace(verb); + ArgumentNullException.ThrowIfNull(handler); + return NoOpLease.Instance; + } + + private sealed class NoOpLease : IDisposable + { + public static NoOpLease Instance { get; } = new(); + public void Dispose() + { + } + } +} diff --git a/src/AcDream.Plugin.Abstractions/ProjectileAutomation.cs b/src/AcDream.Plugin.Abstractions/ProjectileAutomation.cs new file mode 100644 index 00000000..19cece09 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/ProjectileAutomation.cs @@ -0,0 +1,91 @@ +using System.Numerics; + +namespace AcDream.Plugin.Abstractions; + +/// The trajectory family VTank asks the client to validate. +public enum PluginProjectilePathKind +{ + Straight = 0, + Arc, + Missile, +} + +/// Why a projectile-path query did or did not admit the shot. +public enum PluginProjectilePathStatus +{ + Unavailable = 0, + Clear, + Blocked, + InvalidTarget, + BudgetExceeded, + Error, +} + +/// One VTank collision-debug marker in client world coordinates. +public readonly record struct PluginProjectileDebugSample( + Vector3 WorldPosition, + bool IsClear, + float Radius); + +/// +/// Detached result of one bounded collision probe. The host reports geometry; +/// the plugin still decides whether to cast, fire, or choose a fallback. +/// +public readonly record struct PluginProjectilePathResult( + PluginProjectilePathStatus Status, + int CollisionChecks = 0, + uint BlockingObjectId = 0u, + string? Notice = null) +{ + public bool IsClear => Status == PluginProjectilePathStatus.Clear; + public IReadOnlyList DebugSamples + { get; init; } = Array.Empty(); +} + +/// +/// Canonical client-world projectile collision projection. Implementations +/// must use the same resident collision world as ordinary client physics and +/// must never fabricate a successful path when that world is unavailable. +/// +public interface IProjectileAutomation +{ + bool IsAvailable => false; + + PluginProjectilePathResult EvaluatePath( + uint targetObjectId, + PluginProjectilePathKind kind, + PluginAttackHeight targetHeight, + float projectileRadius, + float stepDistance, + int maximumCollisionChecks) => + new(PluginProjectilePathStatus.Unavailable); + + /// + /// Same bounded query with VTank's optional per-quantum debug markers. + /// Older hosts safely fall back to the ordinary result. + /// + PluginProjectilePathResult EvaluatePathWithDiagnostics( + uint targetObjectId, + PluginProjectilePathKind kind, + PluginAttackHeight targetHeight, + float projectileRadius, + float stepDistance, + int maximumCollisionChecks) => + EvaluatePath( + targetObjectId, + kind, + targetHeight, + projectileRadius, + stepDistance, + maximumCollisionChecks); + + /// + /// Presents a transient copy of diagnostic samples in the game view. + /// Graphical hosts draw VTank's green clear/red blocked markers; headless + /// and older hosts deliberately ignore the request. + /// + void ShowDebugSamples( + IReadOnlyList samples) + { + } +} diff --git a/src/AcDream.Plugin.Abstractions/RecoveryAutomation.cs b/src/AcDream.Plugin.Abstractions/RecoveryAutomation.cs new file mode 100644 index 00000000..d06fa3cb --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/RecoveryAutomation.cs @@ -0,0 +1,21 @@ +namespace AcDream.Plugin.Abstractions; + +/// Result of one explicit operator recovery operation. +public readonly record struct PluginRecoveryResult( + bool Accepted, + int PreviousCount = 0, + int CurrentCount = 0, + string Message = ""); + +/// +/// Narrow debug/recovery access to host-owned action state. Normal plugin +/// policy must wait for authoritative receipts; these operations exist for +/// VTank-compatible operator commands that deliberately recover a stuck +/// client-side reference. +/// +public interface IRecoveryAutomation +{ + PluginRecoveryResult ClearOneBusyReference() => new( + Accepted: false, + Message: "Action recovery is unavailable on this host."); +} diff --git a/src/AcDream.Plugin.Abstractions/SelectionAutomation.cs b/src/AcDream.Plugin.Abstractions/SelectionAutomation.cs new file mode 100644 index 00000000..44df4ae4 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/SelectionAutomation.cs @@ -0,0 +1,18 @@ +namespace AcDream.Plugin.Abstractions; + +/// +/// Retail target-cycle actions needed by automation which intentionally +/// changes selection. These invoke the same selection query/controller as +/// keyboard bindings; plugins do not synthesize physical key input. +/// +public enum PluginSelectionAction +{ + PreviousSelection = 0, + PreviousPlayer, + NextPlayer, +} + +public interface ISelectionAutomation +{ + bool Execute(PluginSelectionAction action) => false; +} diff --git a/src/AcDream.Plugin.Abstractions/WorldObjectAutomation.cs b/src/AcDream.Plugin.Abstractions/WorldObjectAutomation.cs new file mode 100644 index 00000000..46cdecac --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/WorldObjectAutomation.cs @@ -0,0 +1,117 @@ +namespace AcDream.Plugin.Abstractions; + +/// +/// Virindi/Decal's stable ObjectClass numbers. These are deliberately distinct +/// from retail's ItemType flags: expressions and imported metas commonly use +/// numeric ObjectClass values (for example 5 = Monster and 24 = Player). +/// +public enum PluginObjectClass +{ + Unknown = 0, + MeleeWeapon = 1, + Armor = 2, + Clothing = 3, + Jewelry = 4, + Monster = 5, + Food = 6, + Money = 7, + Misc = 8, + MissileWeapon = 9, + Container = 10, + Gem = 11, + SpellComponent = 12, + Key = 13, + Portal = 14, + TradeNote = 15, + ManaStone = 16, + Plant = 17, + BaseCooking = 18, + BaseAlchemy = 19, + BaseFletching = 20, + CraftedCooking = 21, + CraftedAlchemy = 22, + CraftedFletching = 23, + Player = 24, + Vendor = 25, + Door = 26, + Corpse = 27, + Lifestone = 28, + HealingKit = 29, + Lockpick = 30, + WandStaffOrb = 31, + Bundle = 32, + Book = 33, + Journal = 34, + Sign = 35, + Housing = 36, + Npc = 37, + Foci = 38, + Salvage = 39, + Ust = 40, + Services = 41, + Scroll = 42, + CombatPet = 43, +} + +/// +/// Detached canonical world-object projection for general plugins and +/// expression engines. The host reports facts; filtering and automation +/// policy remain in the plugin. +/// +public readonly record struct PluginWorldObject( + uint ObjectId, + uint WeenieClassId, + string Name, + PluginObjectClass ObjectClass, + uint ItemType, + uint ContainerObjectId, + uint WielderObjectId) +{ + public bool IsOwned { get; init; } + public bool IsLandscape { get; init; } + public bool HasPosition { get; init; } + public PluginNavigationPosition Position { get; init; } + public bool HasAppraisalData { get; init; } + /// + /// Decal-compatible monotonic millisecond tick of the latest successful + /// identify response for this exact object lifetime. + /// + public int LastIdTime { get; init; } + public bool IsDoorOpen { get; init; } + public int StackSize { get; init; } = 1; + public int ItemsCapacity { get; init; } + public int ContainersCapacity { get; init; } + public IReadOnlyList SpellIds { get; init; } = Array.Empty(); + public IReadOnlyList ActiveSpellIds { get; init; } = Array.Empty(); +} + +/// +/// General object discovery used by UtilityBelt expressions and third-party +/// plugins. It borrows the same Runtime entity directory and ClientObject table +/// as world rendering and inventory; no plugin-specific mirror is introduced. +/// +public interface IWorldObjectAutomation +{ + bool IsAvailable => false; + uint OpenContainerObjectId => 0u; + + IReadOnlyList CaptureObjects() => + Array.Empty(); + + bool TryGet(uint objectId, out PluginWorldObject value) + { + value = default; + return false; + } + + bool TryCaptureProperties( + uint objectId, + out PluginItemProperties properties) + { + properties = default; + return false; + } + + PluginItemCommandResult Identify(uint objectId) => + new(PluginItemCommandStatus.Unavailable); +} diff --git a/src/AcDream.Plugin.Abstractions/WorldTimeAutomation.cs b/src/AcDream.Plugin.Abstractions/WorldTimeAutomation.cs new file mode 100644 index 00000000..62a70eff --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/WorldTimeAutomation.cs @@ -0,0 +1,20 @@ +namespace AcDream.Plugin.Abstractions; + +/// Authoritative Dereth calendar facts projected from Runtime. +public readonly record struct PluginWorldTimeSnapshot( + bool IsAvailable, + double GameTicks, + int Year, + int Month, + int Day, + int Hour, + string MonthName, + string HourName, + bool IsDay, + double MinutesUntilDay, + double MinutesUntilNight); + +public interface IWorldTimeAutomation +{ + PluginWorldTimeSnapshot Snapshot => default; +} diff --git a/src/AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.csproj b/src/AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.csproj index b5ea7958..a9474e63 100644 --- a/src/AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.csproj +++ b/src/AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.csproj @@ -22,8 +22,7 @@ PreserveNewest - - PreserveNewest - + + diff --git a/src/AcDream.Plugins.MossTank/AttackSpellCatalog.cs b/src/AcDream.Plugins.MossTank/AttackSpellCatalog.cs new file mode 100644 index 00000000..a1291ac5 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/AttackSpellCatalog.cs @@ -0,0 +1,408 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal enum AttackSpellShape +{ + Direct, + Arc, + Streak, + Ring, + Harm, + Drain, + Martyr, +} + +internal readonly record struct AttackSpellChoice( + PluginSpellInfo Spell, + AttackSpellShape Shape, + MonsterDamageType DamageType, + bool CastWithoutTarget); + +/// +/// VTank's attack vocabulary projected from the learned retail spell table. +/// Shape and element are derived from stable retail spell names/descriptions; +/// the host remains a policy-free provider of canonical DAT metadata. +/// +internal sealed class AttackSpellCatalog +{ + private const uint TuskerFistsSpellId = 0x0B76u; + private readonly AttackSpellChoice[] _choices; + + private AttackSpellCatalog(AttackSpellChoice[] choices) => + _choices = choices; + + public static AttackSpellCatalog Build( + IReadOnlyList spells) + { + ArgumentNullException.ThrowIfNull(spells); + var choices = new List(); + foreach (PluginSpellInfo spell in spells) + { + if (TryClassify(spell, out AttackSpellChoice choice)) + choices.Add(choice); + } + return new AttackSpellCatalog([.. choices]); + } + + /// + /// Returns VTank's preferred spell forms in retry order. Cast feasibility + /// stays with the host's exact gate, so a lower known tier can be selected + /// when the character cannot currently cast the strongest one. + /// + public IReadOnlyList Candidates( + MonsterRuleActions actions, + CombatSettings settings, + PluginCombatTarget target, + int nearbyRingTargets, + ICharacterInfo character) + { + ArgumentNullException.ThrowIfNull(actions); + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(character); + + MonsterDamageType damageMode = ResolveDamageMode( + actions.DamageType, + character); + bool ringDue = actions.UsesRing + && nearbyRingTargets >= (actions.UsesPrimaryAttack + ? Math.Max(1, settings.MinimumRingTargets) + : 1); + var candidates = new List(); + foreach (AttackSpellChoice choice in _choices) + { + if (!MatchesDamageMode(choice, damageMode)) + continue; + if (choice.Shape == AttackSpellShape.Ring && !ringDue) + continue; + if (choice.Shape != AttackSpellShape.Ring + && !MatchesPrimaryShape(choice.Shape, actions, settings, target)) + { + continue; + } + candidates.Add(choice); + } + + candidates.Sort((left, right) => Compare( + left, + right, + actions with { DamageType = damageMode }, + settings, + target, + ringDue, + character)); + return candidates; + } + + private static int Compare( + AttackSpellChoice left, + AttackSpellChoice right, + MonsterRuleActions actions, + CombatSettings settings, + PluginCombatTarget target, + bool ringDue, + ICharacterInfo character) + { + // VTank resolves Auto through GameInfoDB before it chooses the + // bolt/arc/streak form. Element preference therefore outranks spell + // shape and tier; an unavailable preferred element naturally falls + // through to the next candidate in the ordered list. + if (actions.DamageType == MonsterDamageType.Auto) + { + int leftDamage = VtankDamageDatabase.PreferenceIndex( + target, + left.DamageType); + int rightDamage = VtankDamageDatabase.PreferenceIndex( + target, + right.DamageType); + int damage = leftDamage.CompareTo(rightDamage); + if (damage != 0) + return damage; + } + + int leftPreference = Preference( + left.Shape, actions, settings, target, ringDue, character); + int rightPreference = Preference( + right.Shape, actions, settings, target, ringDue, character); + int preferred = leftPreference.CompareTo(rightPreference); + if (preferred != 0) + return preferred; + + int tier = right.Spell.Tier.CompareTo(left.Spell.Tier); + if (tier != 0) + return tier; + int difficulty = right.Spell.Difficulty.CompareTo(left.Spell.Difficulty); + return difficulty != 0 + ? difficulty + : left.Spell.SpellId.CompareTo(right.Spell.SpellId); + } + + private static int Preference( + AttackSpellShape shape, + MonsterRuleActions actions, + CombatSettings settings, + PluginCombatTarget target, + bool ringDue, + ICharacterInfo character) + { + if (ringDue && shape == AttackSpellShape.Ring) + return 0; + + if (actions.DamageType == MonsterDamageType.DrainAuto) + { + bool needsHealth = character.MaxHealth != 0u + && character.CurrentHealth / (double)character.MaxHealth < 0.75d; + if (needsHealth && shape == AttackSpellShape.Drain) + return 1; + if (!needsHealth + && character.MaxHealth != 0u + && character.CurrentHealth / (double)character.MaxHealth >= 0.5d + && shape == AttackSpellShape.Martyr) + { + return 1; + } + return shape switch + { + AttackSpellShape.Drain => 2, + AttackSpellShape.Martyr => 3, + AttackSpellShape.Harm => 4, + _ => 20, + }; + } + + if (actions.UsesStreak) + { + if (shape == AttackSpellShape.Streak) + return 1; + if (settings.UseArcs && target.Distance >= settings.ArcRange) + return shape == AttackSpellShape.Arc ? 2 : 3; + return shape == AttackSpellShape.Direct ? 2 : 3; + } + if (settings.UseArcs && target.Distance >= settings.ArcRange) + { + if (shape == AttackSpellShape.Arc) + return 1; + if (shape == AttackSpellShape.Direct) + return 2; + } + else + { + if (shape == AttackSpellShape.Direct) + return 1; + if (shape == AttackSpellShape.Arc) + return 2; + } + + return shape switch + { + AttackSpellShape.Harm => 1, + AttackSpellShape.Streak => 3, + AttackSpellShape.Arc => 4, + AttackSpellShape.Direct => 5, + _ => 10, + }; + } + + private static bool MatchesPrimaryShape( + AttackSpellShape shape, + MonsterRuleActions actions, + CombatSettings settings, + PluginCombatTarget target) + { + if (actions.DamageType == MonsterDamageType.DrainAuto) + { + return shape is AttackSpellShape.Drain + or AttackSpellShape.Martyr + or AttackSpellShape.Harm; + } + if (actions.DamageType == MonsterDamageType.Harm) + return shape == AttackSpellShape.Harm; + if (actions.UsesStreak) + { + // Streak is preferred, not a hard requirement: VTank falls back + // when the matching streak/tier is unknown or presently gated. + return shape is AttackSpellShape.Streak + or AttackSpellShape.Direct + or AttackSpellShape.Arc; + } + return shape is AttackSpellShape.Direct or AttackSpellShape.Arc; + } + + private static bool MatchesDamageMode( + AttackSpellChoice choice, + MonsterDamageType requested) + { + return requested switch + { + MonsterDamageType.Harm => choice.Shape == AttackSpellShape.Harm, + MonsterDamageType.DrainAuto => choice.Shape is AttackSpellShape.Drain + or AttackSpellShape.Martyr + or AttackSpellShape.Harm, + MonsterDamageType.VoidBasic or MonsterDamageType.Nether => + choice.DamageType == MonsterDamageType.Nether, + MonsterDamageType.Auto => choice.DamageType is not MonsterDamageType.Auto + && choice.Shape is not (AttackSpellShape.Harm + or AttackSpellShape.Drain + or AttackSpellShape.Martyr), + _ => choice.DamageType == requested, + }; + } + + private static MonsterDamageType ResolveDamageMode( + MonsterDamageType requested, + ICharacterInfo character) + { + // VTank's ga/hi pair treats Prismatic as an ammunition policy while + // retaining normal GameInfoDB element selection for magic. Fists is + // special only while the Tusker Fists enchantment is active; + // otherwise ga resolves the attack element to Bludgeon. + if (requested == MonsterDamageType.Prismatic) + return MonsterDamageType.Auto; + if (requested == MonsterDamageType.Fists) + { + return character.ActiveEnchantments.Any( + static enchantment => enchantment.SpellId == TuskerFistsSpellId) + ? MonsterDamageType.Fists + : MonsterDamageType.Bludgeon; + } + if (requested != MonsterDamageType.Auto) + return requested; + + bool hasWar = IsTrained(character, 34u); + if (hasWar) + return MonsterDamageType.Auto; + if (IsTrained(character, 43u)) + return MonsterDamageType.VoidBasic; + return IsTrained(character, 33u) + ? MonsterDamageType.DrainAuto + : MonsterDamageType.Auto; + } + + private static bool IsTrained(ICharacterInfo character, uint skillId) => + character.TryGetSkill(skillId, out PluginSkillInfo skill) + && skill.Training is PluginSkillTraining.Trained + or PluginSkillTraining.Specialized; + + internal static bool TryClassify( + PluginSpellInfo spell, + out AttackSpellChoice choice) + { + string name = Normalize(spell.Name); + AttackSpellShape shape; + MonsterDamageType damage; + + if (spell.SpellId == TuskerFistsSpellId + || name.Equals("Tusker Fists", StringComparison.OrdinalIgnoreCase)) + { + shape = AttackSpellShape.Direct; + damage = MonsterDamageType.Fists; + } + else if (name.StartsWith("Harm Other", StringComparison.OrdinalIgnoreCase)) + { + shape = AttackSpellShape.Harm; + damage = MonsterDamageType.Harm; + } + else if (name.StartsWith( + "Drain Health Other", StringComparison.OrdinalIgnoreCase)) + { + shape = AttackSpellShape.Drain; + damage = MonsterDamageType.DrainAuto; + } + else if (name.StartsWith( + "Martyr's Hecatomb", StringComparison.OrdinalIgnoreCase)) + { + shape = AttackSpellShape.Martyr; + damage = MonsterDamageType.DrainAuto; + } + else + { + if (!spell.IsOffensive + || spell.IsBeneficial + || spell.IsDebuff + || spell.IsDamageOverTime + || DebuffSpellCatalog.TryClassify(spell, out _, out _)) + { + choice = default; + return false; + } + + damage = DamageFromText(spell.Description, name); + if (damage == MonsterDamageType.Auto) + { + choice = default; + return false; + } + + if (name.Contains(" Streak", StringComparison.OrdinalIgnoreCase)) + shape = AttackSpellShape.Streak; + else if (name.Contains(" Arc", StringComparison.OrdinalIgnoreCase)) + shape = AttackSpellShape.Arc; + else if ((spell.TargetMask == 0u || spell.IsUntargeted) + && (name.Contains(" Ring", StringComparison.OrdinalIgnoreCase) + || spell.Description.Contains( + "outward from the caster", + StringComparison.OrdinalIgnoreCase))) + { + shape = AttackSpellShape.Ring; + } + else if (spell.TargetMask != 0u || spell.IsProjectile) + shape = AttackSpellShape.Direct; + else + { + choice = default; + return false; + } + } + + choice = new AttackSpellChoice( + spell, + shape, + damage, + shape == AttackSpellShape.Ring + || spell.IsUntargeted + || spell.TargetMask == 0u); + return true; + } + + private static MonsterDamageType DamageFromText( + string description, + string name) + { + string text = string.Concat(description, " ", name); + if (text.Contains("slashing damage", StringComparison.OrdinalIgnoreCase) + || text.Contains("Blade", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Slash; + if (text.Contains("piercing damage", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Pierce; + if (text.Contains("bludgeoning damage", StringComparison.OrdinalIgnoreCase) + || text.Contains("Shock Wave", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Bludgeon; + if (text.Contains("cold damage", StringComparison.OrdinalIgnoreCase) + || text.Contains("Frost", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Cold; + if (text.Contains("fire damage", StringComparison.OrdinalIgnoreCase) + || text.Contains("Flame", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Fire; + if (text.Contains("acid damage", StringComparison.OrdinalIgnoreCase) + || text.Contains("Acid", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Acid; + if (text.Contains("electric", StringComparison.OrdinalIgnoreCase) + || text.Contains("Lightning", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Electric; + if (text.Contains("nether", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Nether; + // The first six tiers call the piercing line Force Bolt; description + // is authoritative, while this name fallback covers sparse fixtures. + if (text.Contains("Force", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Pierce; + return MonsterDamageType.Auto; + } + + private static string Normalize(string name) + { + const string incantation = "Incantation of "; + return name.StartsWith(incantation, StringComparison.OrdinalIgnoreCase) + ? name[incantation.Length..] + : name; + } +} diff --git a/src/AcDream.Plugins.MossTank/AutoAttackPower.cs b/src/AcDream.Plugins.MossTank/AutoAttackPower.cs new file mode 100644 index 00000000..c4cc97df --- /dev/null +++ b/src/AcDream.Plugins.MossTank/AutoAttackPower.cs @@ -0,0 +1,173 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// Verbatim decision tree from official VTank hi.cs immediately before +/// its call to bo.a(target,power,spell). This odd-looking table is +/// intentional: slash/pierce hybrid weapons use different charge points for +/// single, triple-strike, dual-wield and shield arrangements. +/// +internal static class AutoAttackPower +{ + private const uint MeleeWeapon = 0x00000001u; + private const uint MissileWeapon = 0x00000100u; + private const uint ShieldLocation = 0x00200000u; + private const int SlashDamage = 0x0001; + private const int PierceDamage = 0x0002; + private const int TripleSlashAttack = 0x0040; + private const uint RecklessnessSkill = 50u; + + public static float Resolve( + MonsterRuleActions actions, + CombatSettings settings, + ICharacterInfo character, + IReadOnlyList inventory) + { + ArgumentNullException.ThrowIfNull(actions); + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(character); + ArgumentNullException.ThrowIfNull(inventory); + if (!settings.AutoAttackPower) + return settings.AttackPower; + + PluginInventoryItem? weapon = FindWeapon(actions, inventory); + if (weapon is not { } selected) + return settings.AttackPower; + if ((selected.ItemType & MissileWeapon) != 0u) + return ClampForRecklessness(1f, settings, character); + if ((selected.ItemType & MeleeWeapon) == 0u) + return settings.AttackPower; + + int requestedDamage = RawDamage(actions.DamageType); + if (requestedDamage is not (SlashDamage or PierceDamage)) + return ClampForRecklessness(1f, settings, character); + + PluginInventoryItem? offhand = FindOffhand(actions, selected, inventory); + bool offhandMelee = offhand is { } held + && (held.ItemType & MeleeWeapon) != 0u; + bool offhandShield = offhand is { } shield + && (shield.EquippedLocation & ShieldLocation) != 0u; + bool slashPierce = (selected.DamageType & (SlashDamage | PierceDamage)) + == (SlashDamage | PierceDamage); + bool tripleSlash = (selected.AttackType & TripleSlashAttack) != 0; + + float power; + if (selected.WeaponType == 1 && !offhandMelee) + { + power = requestedDamage == SlashDamage && slashPierce ? 0.5f : 0f; + } + else if (requestedDamage == PierceDamage && slashPierce && !tripleSlash) + { + power = 0.2f; + } + else if (requestedDamage == PierceDamage + && slashPierce + && tripleSlash + && offhandMelee) + { + power = 0.49f; + } + else if (requestedDamage != PierceDamage + || !slashPierce + || !tripleSlash + || offhandShield) + { + power = 1f; + } + else + { + power = 0.2f; + } + + return ClampForRecklessness(power, settings, character); + } + + private static PluginInventoryItem? FindWeapon( + MonsterRuleActions actions, + IReadOnlyList inventory) + { + PluginInventoryItem? equipped = null; + PluginInventoryItem? named = null; + foreach (PluginInventoryItem item in inventory) + { + if (actions.WeaponObjectId != 0u + && item.ObjectId == actions.WeaponObjectId) + { + return item; + } + if (!string.IsNullOrWhiteSpace(actions.WeaponName) + && item.Name.Equals(actions.WeaponName, StringComparison.Ordinal)) + { + named ??= item; + } + if (item.IsEquipped + && (item.ItemType & (MeleeWeapon | MissileWeapon)) != 0u) + { + equipped ??= item; + } + } + return named ?? equipped; + } + + private static PluginInventoryItem? FindOffhand( + MonsterRuleActions actions, + PluginInventoryItem weapon, + IReadOnlyList inventory) + { + PluginInventoryItem? equipped = null; + PluginInventoryItem? named = null; + foreach (PluginInventoryItem item in inventory) + { + if (item.ObjectId == weapon.ObjectId) + continue; + if (actions.OffhandObjectId != 0u + && item.ObjectId == actions.OffhandObjectId) + { + return item; + } + if (!string.IsNullOrWhiteSpace(actions.OffhandName) + && item.Name.Equals(actions.OffhandName, StringComparison.Ordinal)) + { + named ??= item; + } + if (item.IsEquipped + && ((item.ItemType & MeleeWeapon) != 0u + || (item.EquippedLocation & ShieldLocation) != 0u)) + { + equipped ??= item; + } + } + return named ?? equipped; + } + + private static float ClampForRecklessness( + float power, + CombatSettings settings, + ICharacterInfo character) + { + if (!settings.UseRecklessness + || !character.TryGetSkill( + RecklessnessSkill, + out PluginSkillInfo recklessness) + || recklessness.Training is not ( + PluginSkillTraining.Trained or PluginSkillTraining.Specialized)) + { + return power; + } + return Math.Clamp(power, 0.11f, 0.9f); + } + + private static int RawDamage(MonsterDamageType damage) => damage switch + { + MonsterDamageType.Slash => SlashDamage, + MonsterDamageType.Pierce => PierceDamage, + MonsterDamageType.Bludgeon => 0x0004, + MonsterDamageType.Cold => 0x0008, + MonsterDamageType.Fire => 0x0010, + MonsterDamageType.Acid => 0x0020, + MonsterDamageType.Electric => 0x0040, + MonsterDamageType.Nether => 0x0400, + _ => 0, + }; +} diff --git a/src/AcDream.Plugins.MossTank/BuffPlan.cs b/src/AcDream.Plugins.MossTank/BuffPlan.cs index e9f7b13d..eef0a884 100644 --- a/src/AcDream.Plugins.MossTank/BuffPlan.cs +++ b/src/AcDream.Plugins.MossTank/BuffPlan.cs @@ -5,18 +5,32 @@ namespace AcDream.Plugins.MossTank; /// Settings that shape a buff pass. Defaults follow Virindi Tank's. public sealed class BuffSettings { + public bool Enabled { get; set; } = true; + /// + /// VTank's separate idle top-off rule. The ordinary rebuff rule always + /// uses ; this wider window is only + /// considered after combat, loot, and navigation have found no work. + /// + public bool IdleBuffTopoff { get; set; } + public double IdleBuffTopoffSeconds { get; set; } = 1200.0; /// /// VTank recasts buffs once they drop below five minutes remaining /// ("all buff spells are recast when they go below 5 minutes"). /// public double RebuffWhenUnderSeconds { get; set; } = 300.0; + public double BuffCastRecastSeconds { get; set; } = 30d; + public double BuffCastRecastResetSeconds { get; set; } = 30d; + public bool FastCastBuffs { get; set; } + public bool RandomHelperBuffs { get; set; } + public double RandomHelperIntervalSeconds { get; set; } = 5d; + public string BlacklistedSpellComponents { get; set; } = string.Empty; /// /// How far the casting skill must exceed a spell's difficulty before the /// tier is considered reliable — VTank's /// SpellDiffExcessThreshold-Buff. /// - public int SkillExcessOverDifficulty { get; set; } = 10; + public int SkillExcessOverDifficulty { get; set; } = 5; /// Buff every attribute (VTank's default). public bool BuffAttributes { get; set; } = true; @@ -26,6 +40,8 @@ public sealed class BuffSettings /// their own profile (BuffProfile_Prots) and casts them by default. /// public bool BuffProtections { get; set; } = true; + public string ProtectionElements { get; set; } = "ALFCBPS"; + public int ProtectionProfileMode { get; set; } = 2; /// /// Self-cast weapon and caster auras — Blood Drinker, Heart Seeker, Swift @@ -38,6 +54,8 @@ public sealed class BuffSettings /// in their own profile (BuffProfile_Banes) and casts them by default. /// public bool BuffBanes { get; set; } = true; + public string BaneElements { get; set; } = "ALFCBPS"; + public int BaneProfileMode { get; set; } = 2; /// /// The vital regeneration rates — Regeneration (health), Rejuvenation @@ -57,6 +75,15 @@ public sealed class BuffSettings /// "automatically buffs every Attribute and Skill you have trained". /// public bool BuffTrainedSkillsOnly { get; set; } = true; + + /// + /// Minimum current skill at which VTank permits buffing an untrained + /// magic school. These are independent because the three schools can be + /// raised and trained independently. + /// + public int BuffWithUntrainedItemSkill { get; set; } = 80; + public int BuffWithUntrainedCreatureSkill { get; set; } = 80; + public int BuffWithUntrainedLifeSkill { get; set; } = 80; } /// @@ -80,8 +107,12 @@ public static class BuffPlan IReadOnlyList attributes, IReadOnlyList active, BuffSettings settings, - bool force = false) + bool force = false, + double? rebuffWhenUnderSeconds = null, + int characterLevel = 0) { + if (!settings.Enabled && !force) + return []; var trainedSkills = new Dictionary( StringComparer.OrdinalIgnoreCase); foreach (PluginSkillInfo skill in skills) @@ -120,16 +151,29 @@ public static class BuffPlan foreach (BuffLine line in lines) { + uint school = line.Tiers.Count == 0 ? 0u : line.Tiers[0].School; + bool schoolAvailable = IsSchoolAvailable( + school, + skills, + settings, + characterLevel); bool wanted = line.Kind switch { BuffTargetKind.Attribute => - settings.BuffAttributes && attributeNames.Contains(line.TargetName), - BuffTargetKind.Skill => trainedSkills.ContainsKey(line.TargetName), - BuffTargetKind.Protection => settings.BuffProtections, - BuffTargetKind.Aura => settings.BuffAuras, - BuffTargetKind.Bane => settings.BuffBanes, - BuffTargetKind.Regeneration => settings.BuffRegeneration, - BuffTargetKind.Other => settings.BuffOther, + schoolAvailable && settings.BuffAttributes + && attributeNames.Contains(line.TargetName), + BuffTargetKind.Skill => schoolAvailable + && (trainedSkills.ContainsKey(line.TargetName) + || IsMagicSchoolName(line.TargetName)), + BuffTargetKind.Protection => + schoolAvailable && settings.BuffProtections + && ProfileAllows(line, settings, bane: false), + BuffTargetKind.Aura => schoolAvailable && settings.BuffAuras, + BuffTargetKind.Bane => schoolAvailable && settings.BuffBanes + && ProfileAllows(line, settings, bane: true), + BuffTargetKind.Regeneration => + schoolAvailable && settings.BuffRegeneration, + BuffTargetKind.Other => schoolAvailable && settings.BuffOther, _ => false, }; if (!wanted) @@ -141,7 +185,8 @@ public static class BuffPlan if (!force && inForce.TryGetValue(line.Family, out var held) && held.Tier >= pick.Tier - && held.Seconds >= settings.RebuffWhenUnderSeconds) + && held.Seconds >= (rebuffWhenUnderSeconds + ?? settings.RebuffWhenUnderSeconds)) { continue; // already covered at this strength, and not expiring } @@ -167,6 +212,91 @@ public static class BuffPlan return ordered; } + private static bool ProfileAllows( + BuffLine line, + BuffSettings settings, + bool bane) + { + int mode = bane + ? settings.BaneProfileMode + : settings.ProtectionProfileMode; + string enabled = mode switch + { + 1 => bane ? settings.BaneElements : settings.ProtectionElements, + 2 => "ALFCBPS", + 3 => string.Empty, + 4 => "B", + 5 => "BPS", + 6 => "BPSA", + 7 => "ALFC", + 8 => "BPSAC", + _ => "ALFCBPS", + }; + char element = ElementCode(line); + return element == '\0' || enabled.IndexOf(element) >= 0; + } + + private static char ElementCode(BuffLine line) + { + string text = line.TargetName + " " + + (line.Tiers.Count == 0 ? string.Empty : line.Tiers[0].Name) + + " " + + (line.Tiers.Count == 0 ? string.Empty : line.Tiers[0].Description); + if (text.Contains("acid", StringComparison.OrdinalIgnoreCase)) + return 'A'; + if (text.Contains("lightning", StringComparison.OrdinalIgnoreCase) + || text.Contains("electric", StringComparison.OrdinalIgnoreCase)) + return 'L'; + if (text.Contains("fire", StringComparison.OrdinalIgnoreCase)) + return 'F'; + if (text.Contains("cold", StringComparison.OrdinalIgnoreCase) + || text.Contains("frost", StringComparison.OrdinalIgnoreCase)) + return 'C'; + if (text.Contains("bludgeon", StringComparison.OrdinalIgnoreCase)) + return 'B'; + if (text.Contains("pierc", StringComparison.OrdinalIgnoreCase)) + return 'P'; + if (text.Contains("slash", StringComparison.OrdinalIgnoreCase)) + return 'S'; + return '\0'; + } + + private static bool IsMagicSchoolName(string name) => + name.Equals("Item Enchantment", StringComparison.OrdinalIgnoreCase) + || name.Equals("Creature Enchantment", StringComparison.OrdinalIgnoreCase) + || name.Equals("Life Magic", StringComparison.OrdinalIgnoreCase); + + private static bool IsSchoolAvailable( + uint school, + IReadOnlyList skills, + BuffSettings settings, + int characterLevel) + { + if (school is not (ItemEnchantmentSkill + or CreatureEnchantmentSkill + or LifeMagicSkill)) + { + return true; + } + foreach (PluginSkillInfo skill in skills) + { + if (skill.SkillId == school + && skill.Training is PluginSkillTraining.Trained + or PluginSkillTraining.Specialized) + { + return true; + } + } + int limit = school switch + { + ItemEnchantmentSkill => settings.BuffWithUntrainedItemSkill, + CreatureEnchantmentSkill => settings.BuffWithUntrainedCreatureSkill, + LifeMagicSkill => settings.BuffWithUntrainedLifeSkill, + _ => int.MaxValue, + }; + return characterLevel <= limit; + } + /// Skill ids of the three schools that carry self-buffs. private const uint CreatureEnchantmentSkill = 31; private const uint ItemEnchantmentSkill = 32; diff --git a/src/AcDream.Plugins.MossTank/CombatController.cs b/src/AcDream.Plugins.MossTank/CombatController.cs new file mode 100644 index 00000000..fe40d9ce --- /dev/null +++ b/src/AcDream.Plugins.MossTank/CombatController.cs @@ -0,0 +1,2059 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// VTank-style target acquisition and the first autocombat state machine. +/// It owns policy only; every snapshot and action comes from the host's +/// canonical Runtime owners through . +/// +internal sealed class CombatController +{ + private const float PowerReleaseEpsilon = 0.005f; + + private readonly IPluginHost _host; + private readonly CombatSettings _settings; + private readonly VitalSettings _vitalSettings; + private readonly DebuffTracker _debuffs = new(); + private readonly CombatFailureTracker _failures = new(); + private readonly PetAutomation _pets = new(); + private IReadOnlyList _targets = + Array.Empty(); + private IReadOnlyList? _combatSpellSnapshot; + private DebuffSpellCatalog _debuffCatalog = + DebuffSpellCatalog.Build(Array.Empty()); + private AttackSpellCatalog _attackCatalog = + AttackSpellCatalog.Build(Array.Empty()); + private double _now; + private double _untilScan; + private uint _targetId; + private ResolvedMonsterRule _targetRule; + private string _targetName = string.Empty; + private float _targetDistance; + private string _targetText = "Target —"; + private string _modeText = "Mode Unknown"; + private PluginCombatMode _lastMode = PluginCombatMode.Unknown; + private bool _paused; + private long _observedPhysicalCompletion; + private long _observedAttackCastCompletion; + private uint _pendingPhysicalTarget; + private uint _pendingAttackSpell; + private uint _pendingAttackTarget; + private PendingItemDebuff? _pendingItemDebuff; + private ulong _observedChatSequence; + private long _observedItemCompletion; + private bool _combatPolicySuspended; + private bool _approachMovementOwned; + private bool _breakableTurnOwned; + private int _dropToPeaceModeRetries; + private Func? _requestAmmunitionCraft; + private Func? _canCraftAmmunition; + private int _randomDamageIndex; + private long _observedJiggleCastCompletion; + private bool _selectionJiggleActive; + private bool _selectionJigglePreviousPlayer; + private double _nextSelectionJiggleAt; + + private static readonly MonsterDamageType[] RandomDamageCycle = + [ + MonsterDamageType.Pierce, + MonsterDamageType.Bludgeon, + MonsterDamageType.Slash, + MonsterDamageType.Acid, + MonsterDamageType.Electric, + MonsterDamageType.Cold, + MonsterDamageType.Fire, + ]; + + public CombatController( + IPluginHost host, + CombatSettings settings, + VitalSettings? vitalSettings = null) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _vitalSettings = vitalSettings ?? new VitalSettings(); + } + + public bool Enabled { get; private set; } + public string Status { get; private set; } = "Combat off"; + public string TargetText => _targetText; + public string ModeText => _modeText; + public bool HasTarget => _targetId != 0u; + + public string ButtonText => Enabled ? "Stop Macro" : "Run Macro"; + + public void BindAmmunitionCraftRequest( + Func canCraft, + Func request) + { + _canCraftAmmunition = canCraft + ?? throw new ArgumentNullException(nameof(canCraft)); + _requestAmmunitionCraft = request + ?? throw new ArgumentNullException(nameof(request)); + } + + public void ClearActionLocks() + { + _host.Automation.Combat.AbortPhysicalAttack(); + StopApproachMovement(); + StopBreakableTurnMovement(); + StopSelectionJiggle(); + _pendingPhysicalTarget = 0u; + _pendingAttackSpell = 0u; + _pendingAttackTarget = 0u; + ClearPendingItemDebuff(); + _debuffs.ClearPending(); + _dropToPeaceModeRetries = 0; + _untilScan = 0d; + if (Enabled) + Status = "Action locks cleared"; + } + + public bool RecordFakeImperil(uint targetObjectId) + { + if (targetObjectId == 0u) + return false; + _debuffs.RecordFakeImperil(targetObjectId, _now); + return true; + } + + public void Toggle() + { + if (Enabled) + { + Disable("Macro stopped"); + _host.Automation.Chat.PostSystemMessage("[MossTank] Macro stopped."); + return; + } + + if (!_host.Automation.IsAvailable) + { + Status = "Not in world"; + return; + } + + Enabled = true; + _paused = false; + _combatPolicySuspended = !_settings.Enabled; + _untilScan = 0d; + Status = _settings.Enabled ? "Scanning for targets" : "Combat disabled"; + _host.Automation.Chat.PostSystemMessage("[MossTank] Macro started."); + } + + public void SetPaused(bool paused) + { + if (_paused == paused) + return; + _paused = paused; + if (paused && Enabled) + { + _host.Automation.Combat.AbortPhysicalAttack(); + StopApproachMovement(); + StopBreakableTurnMovement(); + Status = "Paused for buffing"; + } + else if (Enabled) + { + Status = "Scanning for targets"; + _untilScan = 0d; + } + } + + /// + /// One command-driven equipment step for VTank's /vt equipitemsfor. The + /// synthetic target intentionally supplies only the requested name, which + /// matches VTank's own fake world-object evaluation and its documented + /// limitation that operator-heavy rows may not resolve as expected. + /// + public bool EquipOneStepForMonster(string monsterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(monsterName); + var target = new PluginCombatTarget( + 0u, + monsterName.Trim(), + 0u, + 0f, + 0f, + false, + 1f); + _targetName = target.Name; + _targetRule = _settings.ResolveRule(target); + return !TickEquipment(); + } + + public void OnTick(double elapsedSeconds, bool navigationEnabled = true) + { + if (!Enabled) + return; + if (!_host.Automation.IsAvailable) + { + Disable("Session ended"); + return; + } + if (_paused) + return; + if (!_settings.Enabled) + { + if (_combatPolicySuspended) + return; + _combatPolicySuspended = true; + if (_targetId != 0u || _pendingPhysicalTarget != 0u) + _host.Automation.Combat.AbortPhysicalAttack(); + _pendingPhysicalTarget = 0u; + _pendingAttackSpell = 0u; + _pendingAttackTarget = 0u; + ClearPendingItemDebuff(); + _debuffs.Reset(); + _failures.Reset(); + ClearTarget(); + Status = "Combat disabled"; + return; + } + if (_combatPolicySuspended) + { + _combatPolicySuspended = false; + _untilScan = 0d; + Status = "Scanning for targets"; + } + + _now += Math.Max(0d, elapsedSeconds); + PluginCastCompletion castCompletion = + _host.Automation.Magic.LastCompletion; + ObserveSelectionJiggle(castCompletion); + TickSelectionJiggle(); + DebuffCompletion completion = _debuffs.Observe( + castCompletion, + _now); + if (completion.Completed && !completion.Succeeded) + { + Status = $"{completion.SpellName} failed (0x{completion.WeenieError:X})"; + } + _debuffs.ExpirePending(_now); + + PluginCombatSnapshot current = _host.Automation.Combat.Snapshot; + ObserveItemDebuffReceipts(); + ObserveAttackReceipts(current, castCompletion); + if (current.Mode != _lastMode) + { + _lastMode = current.Mode; + _modeText = $"Mode {current.Mode}"; + } + + _untilScan -= Math.Max(0d, elapsedSeconds); + if (_untilScan <= 0d) + { + float acquisitionRange = navigationEnabled + ? Math.Max(_settings.MaximumRange, _settings.ApproachDistance) + : _settings.MaximumRange; + _targets = _host.Automation.Combat.CaptureHostileTargets( + acquisitionRange); + foreach (uint ghost in _failures.ObserveTargets( + _targets, + _now, + _settings)) + { + DismissGhost(ghost); + } + _debuffs.RetainTargets( + _targets.Select(static target => target.ObjectId).ToHashSet()); + _untilScan = Math.Max(0.05d, _settings.ScanIntervalSeconds); + RefreshTarget(); + } + + if (_pendingItemDebuff is not null) + { + TickPendingItemDebuff(current); + return; + } + + if (_targetId == 0u) + { + StopApproachMovement(); + PluginCombatSnapshot idle = _host.Automation.Combat.Snapshot; + if (_settings.IdlePeaceMode + && idle.Mode is not (PluginCombatMode.Unknown + or PluginCombatMode.Peace)) + { + PluginCombatCommandResult result = + _host.Automation.Combat.EnterMode(PluginCombatMode.Peace); + Status = result.Status == PluginCombatCommandStatus.Refused + ? result.Notice ?? "Cannot enter peace mode" + : "Entering peace mode"; + return; + } + Status = "Waiting for a target"; + return; + } + + if (_targetDistance > _settings.MaximumRange) + { + if (navigationEnabled + && _settings.ApproachDistance > _settings.MaximumRange + && _targetDistance <= _settings.ApproachDistance + && TickApproach()) + { + return; + } + + StopApproachMovement(); + Status = $"{_targetName} is out of attack range"; + return; + } + StopApproachMovement(); + + if (_pets.Tick( + _host.Automation.Items, + _host.Automation.Character, + _targets, + _settings, + _now, + out string petStatus)) + { + Status = petStatus; + return; + } + + PluginCombatSnapshot combat = _host.Automation.Combat.Snapshot; + if (TickDebuffs(combat)) + return; + + if (TickEquipment()) + return; + + combat = _host.Automation.Combat.Snapshot; + if (combat.Mode is PluginCombatMode.Unknown or PluginCombatMode.Peace) + { + PluginCombatCommandResult mode = + _host.Automation.Combat.EnterDefaultMode(); + Status = mode.Status == PluginCombatCommandStatus.Refused + ? mode.Notice ?? "Cannot enter combat mode" + : "Entering combat mode"; + return; + } + + // A VTank row may deliberately request debuffs without primary + // attack. Once its requested debuffs are current, remain idle. + if (!_targetRule.Actions.Attacks) + { + Status = $"Debuffs complete for {_targetName}"; + return; + } + + if (combat.Mode == PluginCombatMode.Magic) + { + TickMagic(); + return; + } + + if (combat.Mode is not (PluginCombatMode.Melee or PluginCombatMode.Missile)) + { + Status = $"Unsupported mode: {combat.Mode}"; + return; + } + + TickPhysical(combat); + } + + private void TickPhysical(PluginCombatSnapshot combat) + { + if (combat.ServerResponsePending || combat.RepeatAttackInProgress) + { + Status = $"Attacking {_targetName}"; + return; + } + + if (combat.RequestInProgress) + { + if (combat.BuildInProgress + && combat.PowerBarLevel + PowerReleaseEpsilon + >= combat.DesiredPower) + { + PluginCombatCommandResult release = + _host.Automation.Combat.ReleasePhysicalAttack(); + Status = release.Status == PluginCombatCommandStatus.Released + ? $"Attacking {_targetName}" + : $"Attack release: {release.Status}"; + } + else + { + Status = $"Charging {combat.PowerBarLevel * 100f:0}%"; + } + return; + } + + IReadOnlyList inventory = + _host.Automation.Items.CaptureOwnedItems(); + MonsterRuleActions physicalActions = ResolvePhysicalActions( + _targetRule.Actions, + FindTarget(_targetId), + inventory); + if (combat.Mode == PluginCombatMode.Missile + && !ProjectilePathIsClear( + _targetId, + PluginProjectilePathKind.Missile, + _settings.AttackHeight, + out PluginProjectilePathResult missilePath)) + { + Status = ProjectileStatus(missilePath, _targetName); + return; + } + float desiredPower = AutoAttackPower.Resolve( + physicalActions, + _settings, + _host.Automation.Character, + inventory); + PluginCombatCommandResult begin = + _host.Automation.Combat.BeginPhysicalAttack( + _targetId, + _settings.AttackHeight, + desiredPower); + Status = begin.Status switch + { + PluginCombatCommandStatus.Started => $"Charging {_targetName}", + PluginCombatCommandStatus.Busy => $"Waiting on {_targetName}", + PluginCombatCommandStatus.InvalidTarget => "Target disappeared", + PluginCombatCommandStatus.WrongMode => "Waiting for combat mode", + _ => $"Attack refused: {begin.Status}", + }; + if (begin.Status == PluginCombatCommandStatus.InvalidTarget) + { + _host.Automation.Combat.AbortPhysicalAttack(); + ClearTarget(); + } + else if (begin.Status == PluginCombatCommandStatus.Started) + { + _pendingPhysicalTarget = _targetId; + _failures.BeginAttack( + _targetId, + FindTarget(_targetId).HealthRevision); + } + } + + private void TickMagic() + { + IMagicCommands magic = _host.Automation.Magic; + if (magic.IsCasting) + { + Status = $"Casting at {_targetName}"; + return; + } + + RefreshSpellCatalogs(); + string? projectileRefusal = null; + MonsterRuleActions attackActions = ResolveRandomDamage( + _targetRule.Actions); + IReadOnlyList choices = _attackCatalog.Candidates( + attackActions, + _settings, + FindTarget(_targetId), + CountNearbyRingTargets(), + _host.Automation.Character); + foreach (AttackSpellChoice choice in choices) + { + if (!CanCastHuntSpell(choice.Spell, FindTarget(_targetId))) + continue; + if (choice.Spell.IsProjectile + && !ProjectilePathIsClear( + _targetId, + choice.Shape == AttackSpellShape.Arc + ? PluginProjectilePathKind.Arc + : PluginProjectilePathKind.Straight, + _settings.AttackHeight, + out PluginProjectilePathResult spellPath)) + { + projectileRefusal = ProjectileStatus(spellPath, _targetName); + Status = projectileRefusal; + continue; + } + if (!choice.CastWithoutTarget + && !ReadyForBreakableTurn(choice.Spell, _targetId)) + { + return; + } + PluginCastGate gate = choice.CastWithoutTarget + ? magic.EvaluateGate(choice.Spell.SpellId) + : magic.EvaluateGate(choice.Spell.SpellId, _targetId); + if (gate != PluginCastGate.Ready) + { + continue; + } + bool dispatched = choice.CastWithoutTarget + ? magic.Cast(choice.Spell.SpellId) + : magic.Cast(choice.Spell.SpellId, _targetId); + if (!dispatched) + { + if (_failures.RecordSpellDidNotStart(_targetId, _settings)) + DismissGhost(_targetId); + continue; + } + + Status = choice.Shape == AttackSpellShape.Ring + ? $"{choice.Spell.Name} around {_targetName}" + : $"{choice.Spell.Name} → {_targetName}"; + if (!choice.CastWithoutTarget) + { + _pendingAttackSpell = choice.Spell.SpellId; + _pendingAttackTarget = _targetId; + _failures.BeginAttack( + _targetId, + FindTarget(_targetId).HealthRevision); + } + return; + } + + // Compatibility for older hosts that only implemented the MT1 direct + // attack list and cannot project the richer combat catalog. + IReadOnlyList fallback = + _host.Automation.Spells.KnownAttackSpells; + if (choices.Count == 0 + && attackActions.DamageType == MonsterDamageType.Auto) + { + foreach (PluginSpellInfo spell in fallback) + { + if (!CanCastHuntSpell(spell, FindTarget(_targetId))) + continue; + if (spell.IsProjectile + && !ProjectilePathIsClear( + _targetId, + PluginProjectilePathKind.Straight, + _settings.AttackHeight, + out PluginProjectilePathResult fallbackPath)) + { + projectileRefusal = ProjectileStatus( + fallbackPath, + _targetName); + Status = projectileRefusal; + continue; + } + if (!ReadyForBreakableTurn(spell, _targetId)) + return; + if (magic.EvaluateGate(spell.SpellId, _targetId) + != PluginCastGate.Ready) + { + continue; + } + if (!magic.Cast(spell.SpellId, _targetId)) + { + if (_failures.RecordSpellDidNotStart(_targetId, _settings)) + DismissGhost(_targetId); + continue; + } + _pendingAttackSpell = spell.SpellId; + _pendingAttackTarget = _targetId; + _failures.BeginAttack( + _targetId, + FindTarget(_targetId).HealthRevision); + Status = $"{spell.Name} → {_targetName}"; + return; + } + } + + Status = projectileRefusal + ?? (choices.Count == 0 && fallback.Count == 0 + ? "No direct attack spell known" + : "No usable attack spell"); + } + + private MonsterRuleActions ResolveRandomDamage(MonsterRuleActions actions) + { + if (actions.DamageType != MonsterDamageType.Random) + return actions; + + // hi::a cycles eDamageElement 0..6 in this exact order whenever the + // attack planner is asked for a Random cast. Advancing here (rather + // than persisting a pseudo-random choice) also lets a temporarily + // unavailable element fall through on the following automation tick. + MonsterDamageType damage = RandomDamageCycle[_randomDamageIndex]; + _randomDamageIndex = (_randomDamageIndex + 1) % RandomDamageCycle.Length; + return actions with { DamageType = damage }; + } + + private bool CanCastHuntSpell( + in PluginSpellInfo spell, + in PluginCombatTarget target) + { + if (SpellComponentPolicy.UsesBlacklistedComponent( + _host.Automation.Spells, + spell, + _settings.BlacklistedSpellComponents)) + { + return false; + } + if (spell.School == 0u + || !_host.Automation.Character.TryGetSkill( + spell.School, + out PluginSkillInfo skill)) + { + return true; + } + if (skill.Current < spell.Difficulty + + _settings.HuntSkillExcessOverDifficulty) + { + return false; + } + + float maximumRange = spell.BaseRangeConstant + + (spell.BaseRangeModifier * skill.Current) + - _settings.SpellRangeFudge; + return maximumRange <= 0f + || target.ObjectId == 0u + || target.Distance <= MathF.Min(75f, maximumRange); + } + + private int CountNearbyRingTargets() + { + int count = 0; + foreach (PluginCombatTarget target in _targets) + { + if (target.Distance > _settings.RingDistance) + continue; + ResolvedMonsterRule resolved = _settings.ResolveRule(target); + if (resolved.Priority >= 0 && resolved.Actions.UsesRing) + count++; + } + return count; + } + + private bool TickEquipment() + { + MonsterRuleActions actions = _targetRule.Actions; + bool primaryRequiresWeapon = actions.UsesPrimaryAttack + || actions.UsesRing; + if (!primaryRequiresWeapon && !_settings.SwitchWandsToDebuff) + return false; + IEquipmentAutomation equipment = _host.Automation.Equipment; + if (!equipment.IsAvailable) + { + // Older/no-window hosts explicitly report unavailable. Do not + // turn a missing optional projection into a permanent combat + // deadlock; the currently equipped set remains authoritative. + return false; + } + if (equipment.IsBusy) + { + Status = "Switching equipment"; + return true; + } + + IReadOnlyList items = + equipment.CaptureOwnedEquipment(); + uint desiredWeapon = ResolveEquipmentObjectId( + actions.WeaponObjectId, + actions.WeaponName, + items); + if (desiredWeapon == 0u && actions.DamageType == MonsterDamageType.Auto) + { + desiredWeapon = SelectAutomaticWeapon( + items, + VtankDamageDatabase.Preferences(FindTarget(_targetId)), + _settings); + } + else if (desiredWeapon == 0u) + { + desiredWeapon = SelectAutomaticWeapon( + items, + actions.DamageType, + _settings); + } + + if (TryEquipIfNeeded(equipment, items, desiredWeapon, "weapon")) + return true; + if (TickAmmunition( + equipment, + items, + desiredWeapon, + actions.DamageType)) + { + return true; + } + if (TryEquipIfNeeded( + equipment, + items, + ResolveEquipmentObjectId( + actions.OffhandObjectId, + actions.OffhandName, + items), + "offhand")) + { + return true; + } + return false; + } + + private bool TickAmmunition( + IEquipmentAutomation equipment, + IReadOnlyList equipmentItems, + uint desiredWeapon, + MonsterDamageType configuredDamage) + { + PluginEquipmentItem launcher = equipmentItems.FirstOrDefault( + item => item.ObjectId == desiredWeapon); + int launcherType = VtankAmmunitionDatabase.LauncherType( + launcher.AmmoType); + if (launcherType == 0) + return false; + + MonsterDamageType damage = configuredDamage; + VtankPrismaticAmmoPolicy prismatic = + VtankPrismaticAmmoPolicy.NoPrismatic; + if (damage == MonsterDamageType.Auto) + { + damage = VtankDamageDatabase.Preferences( + FindTarget(_targetId)).FirstOrDefault(); + prismatic = VtankPrismaticAmmoPolicy.Any; + } + else if (damage == MonsterDamageType.Prismatic) + { + prismatic = VtankPrismaticAmmoPolicy.ForcePrismatic; + } + if (damage is MonsterDamageType.None + or MonsterDamageType.VoidBasic + or MonsterDamageType.DrainAuto + or MonsterDamageType.Harm + or MonsterDamageType.Nether) + { + return false; + } + + IReadOnlyList inventory = + _host.Automation.Items.CaptureOwnedItems(); + var counts = inventory + .GroupBy(static item => item.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + static group => group.Key, + static group => group.Sum(item => Math.Max(1, item.StackSize)), + StringComparer.OrdinalIgnoreCase); + var craftable = new Dictionary( + StringComparer.OrdinalIgnoreCase); + bool IsAvailable(string name) + { + if (counts.GetValueOrDefault(name) >= 1) + return true; + if (craftable.TryGetValue(name, out bool cached)) + return cached; + bool value = _canCraftAmmunition?.Invoke(name, 1) == true; + craftable[name] = value; + return value; + } + + VtankAmmunitionOption? selected = VtankAmmunitionDatabase.Select( + launcherType, + damage, + prismatic, + _settings.UseSpecialAmmo, + _host.Automation.Character, + IsAvailable); + if (selected is not { } option) + { + Status = $"No {damage} ammunition is available"; + return true; + } + + PluginEquipmentItem currentAmmo = equipmentItems.FirstOrDefault( + static item => item.CombatUse == 3 && item.IsEquipped); + if (string.Equals( + currentAmmo.Name, + option.Name, + StringComparison.Ordinal)) + return false; + + PluginEquipmentItem desiredAmmo = equipmentItems.FirstOrDefault( + item => item.Name.Equals(option.Name, StringComparison.Ordinal) + && item.StackSize > 0); + if (desiredAmmo.ObjectId != 0u) + return TryEquipIfNeeded( + equipment, + equipmentItems, + desiredAmmo.ObjectId, + "ammunition"); + + if (_requestAmmunitionCraft?.Invoke(option.Name, 1) == true) + { + Status = "Crafting " + option.Name; + return true; + } + Status = $"Waiting to craft {option.Name}"; + return true; + } + + private static MonsterRuleActions ResolvePhysicalActions( + MonsterRuleActions actions, + in PluginCombatTarget target, + IReadOnlyList inventory) + { + if (actions.DamageType != MonsterDamageType.Auto) + return actions; + + IReadOnlyList preferences = + VtankDamageDatabase.Preferences(target); + foreach (MonsterDamageType damage in preferences) + { + int mask = RawDamageType(damage); + if (mask == 0) + continue; + foreach (PluginInventoryItem item in inventory) + { + if (item.IsEquipped && (item.DamageType & mask) != 0) + return actions with { DamageType = damage }; + } + } + return preferences.Count == 0 + ? actions + : actions with { DamageType = preferences[0] }; + } + + private bool TryEquipIfNeeded( + IEquipmentAutomation equipment, + IReadOnlyList items, + uint objectId, + string role) + { + if (objectId == 0u) + return false; + + PluginEquipmentItem? desired = null; + foreach (PluginEquipmentItem item in items) + { + if (item.ObjectId == objectId) + { + desired = item; + break; + } + } + if (desired is not { } selected || selected.IsEquipped) + return false; + + PluginCombatMode mode = _host.Automation.Combat.Snapshot.Mode; + if (mode != PluginCombatMode.Peace) + { + _dropToPeaceModeRetries++; + if (_dropToPeaceModeRetries + >= _vitalSettings.DropToPeaceModeRetryCount) + { + _dropToPeaceModeRetries = 0; + PluginEquipmentItem? recovery = SelectRecoveryCaster(items); + if (recovery is not { } caster) + { + const string error = "You must add at least one wand to " + + "your Items profile."; + Disable(error); + _host.Automation.Chat.PostSystemMessage( + "[MossTank] " + error); + return true; + } + + PluginItemCommandResult use = + _host.Automation.Items.Use(caster.ObjectId); + Status = use.Status == PluginItemCommandStatus.Started + ? "Warning: stuck combat state; using " + caster.Name + + " to clear it" + : "Combat-state recovery with " + caster.Name + ": " + + use.Status; + return true; + } + + PluginCombatCommandResult peace = + _host.Automation.Combat.EnterMode(PluginCombatMode.Peace); + Status = peace.Status == PluginCombatCommandStatus.Refused + ? peace.Notice ?? "Cannot enter peace mode to equip " + + selected.Name + : "Entering peace mode to equip " + selected.Name; + return true; + } + + _dropToPeaceModeRetries = 0; + + PluginEquipmentCommandResult result = equipment.Equip(objectId); + if (result.Status is PluginEquipmentCommandStatus.Started + or PluginEquipmentCommandStatus.Busy) + { + Status = $"Equipping {selected.Name}"; + return true; + } + if (result.Status == PluginEquipmentCommandStatus.Refused) + Status = $"Cannot equip {role}: {selected.Name}"; + return false; + } + + private PluginEquipmentItem? SelectRecoveryCaster( + IReadOnlyList items) + { + const uint casterItemType = 0x00008000u; + foreach (PluginEquipmentItem item in items) + { + if (item.ItemType != casterItemType) + continue; + if (_settings.CombatItemObjectIds.Contains(item.ObjectId) + || _settings.CombatItemNames.Contains(item.Name)) + { + return item; + } + } + return null; + } + + private static uint SelectAutomaticWeapon( + IReadOnlyList items, + MonsterDamageType damageType, + CombatSettings settings) + { + const uint weaponReadyMask = 0x03500000u; + int rawDamage = RawDamageType(damageType); + PluginEquipmentItem? best = null; + foreach (PluginEquipmentItem item in items) + { + if (!settings.CombatItemObjectIds.Contains(item.ObjectId) + && !settings.CombatItemNames.Contains(item.Name)) + { + continue; + } + if ((item.ValidLocations & weaponReadyMask) == 0u + || rawDamage == 0 + || (item.DamageType & rawDamage) == 0) + { + continue; + } + if (best is null + || item.Damage > best.Value.Damage + || (item.Damage == best.Value.Damage + && item.IsEquipped + && !best.Value.IsEquipped)) + { + best = item; + } + } + return best?.ObjectId ?? 0u; + } + + private static uint SelectAutomaticWeapon( + IReadOnlyList items, + IReadOnlyList preferences, + CombatSettings settings) + { + foreach (MonsterDamageType damage in preferences) + { + uint objectId = SelectAutomaticWeapon(items, damage, settings); + if (objectId != 0u) + return objectId; + } + return 0u; + } + + private static int RawDamageType(MonsterDamageType damageType) => + damageType switch + { + MonsterDamageType.Slash => 0x0001, + MonsterDamageType.Pierce => 0x0002, + MonsterDamageType.Bludgeon => 0x0004, + MonsterDamageType.Cold => 0x0008, + MonsterDamageType.Fire => 0x0010, + MonsterDamageType.Acid => 0x0020, + MonsterDamageType.Electric => 0x0040, + MonsterDamageType.Nether => 0x0400, + _ => 0, + }; + + private bool TickDebuffs(PluginCombatSnapshot combat) + { + if (_debuffs.HasPending) + { + Status = _host.Automation.Magic.IsCasting + ? $"Casting {_debuffs.PendingName}" + : $"Waiting for {_debuffs.PendingName}"; + return true; + } + + RefreshSpellCatalogs(); + IReadOnlyList items = + _host.Automation.Items.CaptureOwnedItems(); + + foreach (RuleCandidate candidate in DebuffScope()) + { + MonsterRuleActions actions = ResolveAutomaticActions( + candidate.Rule.Actions, + candidate.Target, + items); + IReadOnlyList choices = + CombatItemDebuffPlanner.Candidates( + actions, + _settings, + _host.Automation.Character, + _host.Automation.Spells, + items, + (identity, spell) => _debuffs.IsDue( + candidate.Target.ObjectId, + identity, + spell, + _now, + _settings.DebuffPrecastSeconds)); + + foreach (CombatDebuffSource choice in choices) + { + if (SpellComponentPolicy.UsesBlacklistedComponent( + _host.Automation.Spells, + choice.Spell, + _settings.BlacklistedSpellComponents)) + { + continue; + } + if (!ReadyForBreakableTurn( + choice.Spell, + candidate.Target.ObjectId)) + { + return true; + } + if (choice.Spell.IsProjectile + && !ProjectilePathIsClear( + candidate.Target.ObjectId, + choice.Spell.Name.Contains( + " Arc", + StringComparison.OrdinalIgnoreCase) + ? PluginProjectilePathKind.Arc + : choice.Kind is CombatDebuffSourceKind.Grenade + or CombatDebuffSourceKind.ProcWeapon + ? PluginProjectilePathKind.Missile + : PluginProjectilePathKind.Straight, + PluginAttackHeight.Medium, + out PluginProjectilePathResult debuffPath)) + { + Status = ProjectileStatus( + debuffPath, + candidate.Target.Name); + if (_settings.AllowDebuffFallback) + continue; + return true; + } + if (choice.Kind != CombatDebuffSourceKind.LearnedSpell) + { + DebuffStartResult itemResult = TryStartItemDebuff( + choice, + candidate.Target, + combat, + items, + ResolveInventoryObjectId( + actions.OffhandObjectId, + actions.OffhandName, + items)); + if (itemResult == DebuffStartResult.Handled) + return true; + continue; + } + + if (combat.Mode != PluginCombatMode.Magic) + { + EnterDebuffMode(PluginCombatMode.Magic); + return true; + } + PluginCastGate gate = _host.Automation.Magic.EvaluateGate( + choice.Spell.SpellId, + candidate.Target.ObjectId); + if (gate == PluginCastGate.Busy) + { + Status = "Waiting to debuff"; + return true; + } + if (gate != PluginCastGate.Ready + || !_host.Automation.Magic.Cast( + choice.Spell.SpellId, + candidate.Target.ObjectId)) + { + continue; + } + + _debuffs.Begin( + candidate.Target.ObjectId, + choice.Identity, + choice.Spell, + _now, + _host.Automation.Magic.LastCompletion.Revision); + string targetName = string.IsNullOrWhiteSpace(candidate.Target.Name) + ? $"0x{candidate.Target.ObjectId:X8}" + : candidate.Target.Name; + Status = $"{choice.Spell.Name} → {targetName}"; + return true; + } + } + + return false; + } + + private bool ProjectilePathIsClear( + uint targetObjectId, + PluginProjectilePathKind kind, + PluginAttackHeight height, + out PluginProjectilePathResult result) + { + if (!_settings.UseProjectileAwareness) + { + result = new(PluginProjectilePathStatus.Clear); + return true; + } + result = _settings.ShowCollisionDebug + ? _host.Automation.Projectiles.EvaluatePathWithDiagnostics( + targetObjectId, + kind, + height, + _settings.CollisionProjectileRadius, + _settings.CollisionStepDistance, + _settings.MaximumCollisionChecksPerTick) + : _host.Automation.Projectiles.EvaluatePath( + targetObjectId, + kind, + height, + _settings.CollisionProjectileRadius, + _settings.CollisionStepDistance, + _settings.MaximumCollisionChecksPerTick); + if (_settings.ShowCollisionDebug && result.DebugSamples.Count > 0) + { + _host.Automation.Projectiles.ShowDebugSamples(result.DebugSamples); + _host.Log.Info( + $"MossTank collision {kind}: {result.Status}, " + + $"{result.DebugSamples.Count} marker(s), " + + $"{result.CollisionChecks} check(s)"); + } + return result.IsClear; + } + + private static string ProjectileStatus( + in PluginProjectilePathResult result, + string targetName) + { + string target = string.IsNullOrWhiteSpace(targetName) + ? "target" + : targetName; + return result.Status switch + { + PluginProjectilePathStatus.Blocked when result.BlockingObjectId != 0u => + $"Projectile path to {target} blocked by 0x{result.BlockingObjectId:X8}", + PluginProjectilePathStatus.Blocked => + $"Projectile path to {target} is blocked", + PluginProjectilePathStatus.Unavailable => + "Projectile collision data is unavailable", + PluginProjectilePathStatus.BudgetExceeded => + "Projectile collision-check budget exhausted", + PluginProjectilePathStatus.InvalidTarget => + $"Cannot resolve projectile path to {target}", + PluginProjectilePathStatus.Error => + result.Notice ?? "Projectile collision check failed", + _ => $"Cannot fire at {target}", + }; + } + + private MonsterRuleActions ResolveAutomaticActions( + MonsterRuleActions actions, + in PluginCombatTarget target, + IReadOnlyList inventory) + { + if (actions.DamageType != MonsterDamageType.Auto) + return actions; + + IReadOnlyList preferences = + VtankDamageDatabase.Preferences(target); + const uint weaponReadyMask = 0x03500000u; + foreach (MonsterDamageType damage in preferences) + { + int rawDamage = RawDamageType(damage); + foreach (PluginInventoryItem item in inventory) + { + bool profiled = _settings.CombatItemObjectIds.Contains( + item.ObjectId) + || _settings.CombatItemNames.Contains(item.Name); + if (profiled + && (item.ValidLocations & weaponReadyMask) != 0u + && (item.DamageType & rawDamage) != 0) + { + return actions with { DamageType = damage }; + } + } + } + + ICharacterInfo character = _host.Automation.Character; + if (IsTrained(character, 34u)) + { + return preferences.Count == 0 + ? actions + : actions with { DamageType = preferences[0] }; + } + if (IsTrained(character, 43u)) + return actions with { DamageType = MonsterDamageType.VoidBasic }; + if (IsTrained(character, 33u)) + return actions with { DamageType = MonsterDamageType.DrainAuto }; + return preferences.Count == 0 + ? actions + : actions with { DamageType = preferences[0] }; + } + + private static bool IsTrained(ICharacterInfo character, uint skillId) => + character.TryGetSkill(skillId, out PluginSkillInfo skill) + && skill.Training is PluginSkillTraining.Trained + or PluginSkillTraining.Specialized; + + private DebuffStartResult TryStartItemDebuff( + CombatDebuffSource source, + PluginCombatTarget target, + PluginCombatSnapshot combat, + IReadOnlyList inventory, + uint desiredOffhand) + { + IEquipmentAutomation equipment = _host.Automation.Equipment; + if (!equipment.IsAvailable) + return DebuffStartResult.Skipped; + if (equipment.IsBusy) + { + Status = $"Equipping {ItemName(source.ItemObjectId, inventory)}"; + return DebuffStartResult.Handled; + } + + PluginInventoryItem item = default; + bool found = false; + foreach (PluginInventoryItem candidate in inventory) + { + if (candidate.ObjectId == source.ItemObjectId) + { + item = candidate; + found = true; + break; + } + } + if (!found) + return DebuffStartResult.Skipped; + + if (source.Kind == CombatDebuffSourceKind.Grenade + && desiredOffhand != 0u) + { + IReadOnlyList equipmentItems = + equipment.CaptureOwnedEquipment(); + PluginEquipmentItem? offhand = null; + foreach (PluginEquipmentItem candidate in equipmentItems) + { + if (candidate.ObjectId == desiredOffhand) + { + offhand = candidate; + break; + } + } + if (offhand is { IsEquipped: false } selectedOffhand) + { + PluginEquipmentCommandResult offhandResult = + equipment.Equip(selectedOffhand.ObjectId); + if (offhandResult.Accepted + || offhandResult.Status == PluginEquipmentCommandStatus.Busy) + { + Status = $"Equipping {selectedOffhand.Name}"; + return DebuffStartResult.Handled; + } + return DebuffStartResult.Skipped; + } + } + + if (!item.IsEquipped) + { + PluginEquipmentCommandResult equip = equipment.Equip(item.ObjectId); + if (equip.Status is PluginEquipmentCommandStatus.Started + or PluginEquipmentCommandStatus.Busy) + { + Status = $"Equipping {item.Name}"; + return DebuffStartResult.Handled; + } + return DebuffStartResult.Skipped; + } + + PluginCombatMode desiredMode = source.Kind switch + { + CombatDebuffSourceKind.CasterItem => PluginCombatMode.Magic, + CombatDebuffSourceKind.Grenade => PluginCombatMode.Missile, + _ when (item.ItemType & 0x00000100u) != 0u => + PluginCombatMode.Missile, + _ => PluginCombatMode.Melee, + }; + if (combat.Mode != desiredMode) + { + EnterDebuffMode(desiredMode); + return DebuffStartResult.Handled; + } + + string targetName = string.IsNullOrWhiteSpace(target.Name) + ? $"0x{target.ObjectId:X8}" + : target.Name; + if (source.Kind == CombatDebuffSourceKind.CasterItem) + { + IItemAutomation itemCommands = _host.Automation.Items; + if (!itemCommands.IsAvailable || itemCommands.IsBusy) + { + Status = $"Waiting to use {item.Name}"; + return DebuffStartResult.Handled; + } + PluginItemCommandResult apply = itemCommands.Apply( + item.ObjectId, + target.ObjectId); + if (!apply.Accepted) + return DebuffStartResult.Skipped; + _pendingItemDebuff = new PendingItemDebuff( + source, + target.ObjectId, + targetName, + item.Name, + _now, + itemCommands.LastCompletion.Revision, + combat.CompletionRevision, + desiredMode, + 0f); + Status = $"{source.Spell.Name} via {item.Name} → {targetName}"; + return DebuffStartResult.Handled; + } + + if (combat.RequestInProgress + || combat.ServerResponsePending + || combat.RepeatAttackInProgress) + { + Status = $"Waiting to fire {item.Name}"; + return DebuffStartResult.Handled; + } + float power = desiredMode == PluginCombatMode.Missile ? 1f : 0f; + PluginCombatCommandResult begin = + _host.Automation.Combat.BeginPhysicalAttack( + target.ObjectId, + PluginAttackHeight.Medium, + power); + if (begin.Status != PluginCombatCommandStatus.Started) + return begin.Status == PluginCombatCommandStatus.Busy + ? DebuffStartResult.Handled + : DebuffStartResult.Skipped; + _pendingItemDebuff = new PendingItemDebuff( + source, + target.ObjectId, + targetName, + item.Name, + _now, + _host.Automation.Items.LastCompletion.Revision, + combat.CompletionRevision, + desiredMode, + power); + Status = $"Charging {item.Name} for {targetName}"; + return DebuffStartResult.Handled; + } + + private static uint ResolveEquipmentObjectId( + uint sessionObjectId, + string durableName, + IReadOnlyList items) + { + if (sessionObjectId != 0u + && items.Any(item => item.ObjectId == sessionObjectId)) + { + return sessionObjectId; + } + if (string.IsNullOrWhiteSpace(durableName)) + return 0u; + foreach (PluginEquipmentItem item in items) + { + if (item.Name.Equals(durableName, StringComparison.Ordinal)) + return item.ObjectId; + } + return 0u; + } + + private static uint ResolveInventoryObjectId( + uint sessionObjectId, + string durableName, + IReadOnlyList items) + { + if (sessionObjectId != 0u + && items.Any(item => item.ObjectId == sessionObjectId)) + { + return sessionObjectId; + } + if (string.IsNullOrWhiteSpace(durableName)) + return 0u; + foreach (PluginInventoryItem item in items) + { + if (item.Name.Equals(durableName, StringComparison.Ordinal)) + return item.ObjectId; + } + return 0u; + } + + private void EnterDebuffMode(PluginCombatMode mode) + { + PluginCombatCommandResult result = + _host.Automation.Combat.EnterMode(mode); + Status = result.Status == PluginCombatCommandStatus.Refused + ? result.Notice ?? $"Cannot enter {mode} mode" + : $"Entering {mode} mode"; + } + + private void ClearPendingItemDebuff() + { + _pendingItemDebuff = null; + } + + private void TickPendingItemDebuff(PluginCombatSnapshot combat) + { + if (_pendingItemDebuff is not { } pending) + return; + if (_now - pending.DispatchedAt >= 15d) + { + _host.Automation.Combat.AbortPhysicalAttack(); + Status = $"{pending.Source.Spell.Name} timed out"; + ClearPendingItemDebuff(); + return; + } + if (pending.Source.Kind == CombatDebuffSourceKind.CasterItem) + { + TickWandCastRecovery(pending); + Status = $"Waiting for {pending.Source.Spell.Name}"; + return; + } + + if (combat.RequestInProgress) + { + if (combat.BuildInProgress + && combat.PowerBarLevel + PowerReleaseEpsilon + >= pending.Power) + { + PluginCombatCommandResult release = + _host.Automation.Combat.ReleasePhysicalAttack(); + Status = release.Status == PluginCombatCommandStatus.Released + ? $"Firing {pending.ItemName}" + : $"Attack release: {release.Status}"; + } + else + { + Status = $"Charging {pending.ItemName}"; + } + return; + } + if (combat.ServerResponsePending || combat.RepeatAttackInProgress) + { + Status = $"Waiting for {pending.Source.Spell.Name}"; + return; + } + if (combat.CompletionRevision > pending.PhysicalCompletionRevision) + { + pending.PhysicalCompletionRevision = combat.CompletionRevision; + pending.AttackCompletedAt ??= _now; + if (combat.CompletionWeenieError != 0u) + { + Status = $"{pending.ItemName} failed (0x{combat.CompletionWeenieError:X})"; + ClearPendingItemDebuff(); + return; + } + } + if (pending.AttackCompletedAt is { } completed + && _now - completed >= 1d) + { + // A proc weapon is allowed to try again until the actual combat + // chat confirms the spell. Grenades are re-resolved from inventory + // because the fired stack/object may have changed. + ClearPendingItemDebuff(); + Status = $"Retrying {pending.Source.Spell.Name}"; + return; + } + Status = $"Waiting for {pending.Source.Spell.Name}"; + } + + private void TickWandCastRecovery(PendingItemDebuff pending) + { + double age = _now - pending.DispatchedAt; + INavigationAutomation movement = _host.Automation.Navigation; + if (_settings.JumpOutWandCasting + && !pending.RecoverySent + && age >= 0.2d) + { + _ = movement.SetMovementIntent(new PluginMovementIntent(Jump: true)); + _ = movement.ClearMovementIntent(); + pending.RecoverySent = true; + return; + } + if (!_settings.DoJiggle || _settings.JumpOutWandCasting) + return; + // VTank's DoJiggle is not movement. gs starts the same 131 ms + // previous-selection/player-cycle controller as ordinary spell casts + // after the wand cast completes. The receipt observer below owns it. + } + + private void ObserveItemDebuffReceipts() + { + foreach (PluginChatMessage message in + _host.Automation.Chat.CaptureMessages(_observedChatSequence)) + { + _observedChatSequence = Math.Max( + _observedChatSequence, + message.Sequence); + if (_pendingItemDebuff is not { } pending + || !IsMatchingCastLine(message.Text, pending.Source.Spell.Name)) + { + continue; + } + _debuffs.RecordApplied( + pending.TargetObjectId, + pending.Source.Identity, + pending.Source.Spell, + _now); + _failures.RecordSuccessfulAttack( + pending.TargetObjectId, + _now, + _settings); + Status = $"{pending.Source.Spell.Name} applied to {pending.TargetName}"; + if (!_settings.JumpOutWandCasting) + StartSelectionJiggle(pending.Source.Spell); + ClearPendingItemDebuff(); + } + + PluginItemUseCompletion itemCompletion = + _host.Automation.Items.LastCompletion; + if (itemCompletion.Revision <= _observedItemCompletion) + return; + _observedItemCompletion = itemCompletion.Revision; + if (_pendingItemDebuff is not { } itemPending + || itemPending.Source.Kind != CombatDebuffSourceKind.CasterItem + || itemCompletion.SourceObjectId != itemPending.Source.ItemObjectId + || itemCompletion.TargetObjectId != itemPending.TargetObjectId + || itemCompletion.IsSuccess) + { + return; + } + Status = $"{itemPending.ItemName} failed (0x{itemCompletion.WeenieError:X})"; + ClearPendingItemDebuff(); + } + + private static bool IsMatchingCastLine(string text, string spellName) => + text.StartsWith($"You cast {spellName} on ", StringComparison.Ordinal); + + private void ObserveSelectionJiggle(in PluginCastCompletion completion) + { + if (_host.Automation.Magic.IsCasting) + { + StopSelectionJiggle(); + return; + } + if (completion.Revision <= _observedJiggleCastCompletion) + return; + _observedJiggleCastCompletion = completion.Revision; + if (completion.IsSuccess + && _host.Automation.Spells.TryGet( + completion.SpellId, + out PluginSpellInfo spell)) + { + StartSelectionJiggle(spell); + } + } + + private void StartSelectionJiggle(in PluginSpellInfo spell) + { + if (!_settings.DoJiggle + || (IsVtankInstantCast(spell) + && spell.School is 34u or 43u)) + { + return; + } + ISelectionAutomation selection = _host.Automation.Selection; + if (!selection.Execute(PluginSelectionAction.PreviousSelection)) + return; + _selectionJiggleActive = true; + _selectionJigglePreviousPlayer = false; + _nextSelectionJiggleAt = _now; + } + + private void TickSelectionJiggle() + { + if (!_selectionJiggleActive || _now < _nextSelectionJiggleAt) + return; + ISelectionAutomation selection = _host.Automation.Selection; + int pulses = 0; + do + { + PluginSelectionAction action = _selectionJigglePreviousPlayer + ? PluginSelectionAction.PreviousPlayer + : PluginSelectionAction.NextPlayer; + if (!selection.Execute(action)) + { + StopSelectionJiggle(); + return; + } + _selectionJigglePreviousPlayer = !_selectionJigglePreviousPlayer; + _nextSelectionJiggleAt += 0.131d; + } + while (_now >= _nextSelectionJiggleAt && ++pulses < 8); + } + + private void StopSelectionJiggle() + { + _selectionJiggleActive = false; + _selectionJigglePreviousPlayer = false; + _nextSelectionJiggleAt = 0d; + } + + private static bool IsVtankInstantCast(in PluginSpellInfo spell) + { + if (spell.Difficulty < 50) + return true; + if (spell.IsUntargeted + && !spell.IsFellowship + && spell.DurationSeconds >= 60f + && spell.School is 31u or 33u) + { + return true; + } + return spell.Family is >= 243u and <= 249u or 639u; + } + + private static string ItemName( + uint objectId, + IReadOnlyList inventory) + { + foreach (PluginInventoryItem item in inventory) + { + if (item.ObjectId == objectId) + return item.Name; + } + return $"0x{objectId:X8}"; + } + + private void RefreshSpellCatalogs() + { + IReadOnlyList spells = + _host.Automation.Spells.KnownCombatSpells; + if (ReferenceEquals(spells, _combatSpellSnapshot)) + return; + _combatSpellSnapshot = spells; + _debuffCatalog = DebuffSpellCatalog.Build(spells); + _attackCatalog = AttackSpellCatalog.Build(spells); + } + + private IReadOnlyList DebuffScope() + { + if (_settings.DebuffEachFirst == DebuffEachFirst.One) + { + return _targetId == 0u + ? Array.Empty() + : [new RuleCandidate( + FindTarget(_targetId), + _targetRule)]; + } + + var candidates = new List(); + foreach (PluginCombatTarget target in _targets) + { + if (target.Distance < _settings.MinimumRange) + continue; + if (_failures.Reason(target.ObjectId, _now) + != CombatSuppressionReason.None) + { + continue; + } + ResolvedMonsterRule rule = _settings.ResolveRule(target); + if (rule.Priority < 0) + continue; + if (_settings.DebuffEachFirst == DebuffEachFirst.Priority + && rule.Priority != _targetRule.Priority) + { + continue; + } + candidates.Add(new RuleCandidate(target, rule)); + } + candidates.Sort(static (left, right) => + { + int priority = right.Rule.Priority.CompareTo(left.Rule.Priority); + if (priority != 0) + return priority; + int distance = left.Target.Distance.CompareTo(right.Target.Distance); + return distance != 0 + ? distance + : left.Target.ObjectId.CompareTo(right.Target.ObjectId); + }); + return candidates; + } + + private PluginCombatTarget FindTarget(uint objectId) + { + foreach (PluginCombatTarget target in _targets) + { + if (target.ObjectId == objectId) + return target; + } + return default; + } + + private void RefreshTarget() + { + if (_targetId != 0u + && _failures.Reason(_targetId, _now) + != CombatSuppressionReason.None) + { + _host.Automation.Combat.AbortPhysicalAttack(); + ClearTarget(); + } + + PluginCombatSnapshot combat = _host.Automation.Combat.Snapshot; + bool actionInFlight = combat.BuildInProgress + || combat.RequestInProgress + || combat.ServerResponsePending + || combat.RepeatAttackInProgress + || _host.Automation.Magic.IsCasting; + if (_targetId != 0u && actionInFlight) + { + if (TryFind(_targetId, out PluginCombatTarget active)) + SetTarget(active, _settings.ResolveRule(active)); + else + { + _host.Automation.Combat.AbortPhysicalAttack(); + ClearTarget(); + } + return; + } + + int highestPriority = -1; + var candidates = new List(); + foreach (PluginCombatTarget target in _targets) + { + if (_failures.Reason(target.ObjectId, _now) + != CombatSuppressionReason.None) + { + continue; + } + ResolvedMonsterRule resolved = _settings.ResolveRule(target); + int priority = resolved.Priority; + if (priority < 0) + continue; + if (priority > highestPriority) + { + highestPriority = priority; + candidates.Clear(); + } + if (priority == highestPriority) + candidates.Add(new RuleCandidate(target, resolved)); + } + + if (candidates.Count == 0) + { + if (_targetId != 0u) + _host.Automation.Combat.AbortPhysicalAttack(); + ClearTarget(); + return; + } + + // Target Lock gives a manually selected valid monster first refusal, + // but never lets it beat a higher-priority monster rule. + uint selected = _host.Automation.Combat.Snapshot.SelectedObjectId; + if (_settings.TargetLock && selected != 0u) + { + foreach (RuleCandidate candidate in candidates) + { + if (candidate.Target.ObjectId == selected) + { + SetTarget(candidate.Target, candidate.Rule); + return; + } + } + } + + // Official VTank dz::a keeps PluginCore.dz.o.e (the previously + // selected attack target) ahead of the range/angle comparison after + // priority and manual TargetLock have been resolved. Without this + // tie-break, turning changes every candidate's relative angle and can + // make a surrounded character alternate left/right forever. + if (_targetId != 0u) + { + foreach (RuleCandidate candidate in candidates) + { + if (candidate.Target.ObjectId == _targetId) + { + SetTarget(candidate.Target, candidate.Rule); + return; + } + } + } + + IEnumerable ranked = candidates; + if (_settings.SelectionMethod == TargetSelectionMethod.Both) + { + RuleCandidate[] near = candidates + .Where(candidate => + candidate.Target.Distance <= _settings.TargetSelectAngleRange) + .ToArray(); + ranked = near.Length > 0 ? near : candidates; + } + + RuleCandidate chosen = _settings.SelectionMethod switch + { + TargetSelectionMethod.Angle => ranked + .OrderBy(candidate => + MathF.Abs(candidate.Target.RelativeAngleDegrees)) + .ThenBy(candidate => candidate.Target.Distance) + .First(), + TargetSelectionMethod.Both + when ranked is RuleCandidate[] { Length: > 0 } near => near + .OrderBy(candidate => + MathF.Abs(candidate.Target.RelativeAngleDegrees)) + .ThenBy(candidate => candidate.Target.Distance) + .First(), + _ => ranked + .OrderBy(candidate => candidate.Target.Distance) + .ThenBy(candidate => + MathF.Abs(candidate.Target.RelativeAngleDegrees)) + .First(), + }; + SetTarget(chosen.Target, chosen.Rule); + } + + private bool TryFind(uint objectId, out PluginCombatTarget found) + { + foreach (PluginCombatTarget target in _targets) + { + if (target.ObjectId == objectId) + { + found = target; + return true; + } + } + found = default; + return false; + } + + private void SetTarget( + PluginCombatTarget target, + ResolvedMonsterRule resolved) + { + _targetId = target.ObjectId; + _targetRule = resolved; + _targetName = string.IsNullOrWhiteSpace(target.Name) + ? $"0x{target.ObjectId:X8}" + : target.Name; + _targetDistance = target.Distance; + _targetText = $"Target {_targetName} {_targetDistance:0.0}m"; + _failures.BeginEngagement(_targetId, _now); + } + + private void ClearTarget() + { + StopApproachMovement(); + StopBreakableTurnMovement(); + StopSelectionJiggle(); + _targetId = 0u; + _targetRule = default; + _targetName = string.Empty; + _targetDistance = 0f; + _targetText = "Target —"; + } + + private void Disable(string status) + { + _host.Automation.Combat.AbortPhysicalAttack(); + StopApproachMovement(); + StopBreakableTurnMovement(); + Enabled = false; + _paused = false; + _combatPolicySuspended = false; + _targets = Array.Empty(); + _combatSpellSnapshot = null; + _debuffCatalog = DebuffSpellCatalog.Build(Array.Empty()); + _attackCatalog = AttackSpellCatalog.Build(Array.Empty()); + _debuffs.Reset(); + _failures.Reset(); + _observedPhysicalCompletion = 0; + _observedAttackCastCompletion = 0; + _pendingPhysicalTarget = 0u; + _pendingAttackSpell = 0u; + _pendingAttackTarget = 0u; + ClearPendingItemDebuff(); + _observedChatSequence = 0u; + _observedItemCompletion = 0; + _dropToPeaceModeRetries = 0; + _randomDamageIndex = 0; + _observedJiggleCastCompletion = 0; + ClearTarget(); + Status = status; + } + + private bool TickApproach() + { + INavigationAutomation navigation = _host.Automation.Navigation; + PluginNavigationSnapshot self = navigation.Snapshot; + if (!self.IsAvailable || self.IsPortalSpace + || !navigation.TryGetObject( + _targetId, + out PluginNavigationObject target)) + { + return false; + } + + float desired = NavigationController.DesiredHeading( + self.Position, + target.Position); + float delta = NavigationController.SignedHeadingDelta( + self.Position.HeadingDegrees, + desired); + float absolute = MathF.Abs(delta); + bool turnRight = delta > 4f; + bool turnLeft = delta < -4f; + bool forward = absolute <= 4f + || (_targetDistance > 5f ? absolute <= 45f : absolute <= 15f); + PluginNavigationCommandStatus result = navigation.SetMovementIntent( + new PluginMovementIntent( + Forward: forward, + TurnLeft: turnLeft, + TurnRight: turnRight, + Run: true)); + _approachMovementOwned = + result == PluginNavigationCommandStatus.Accepted; + if (_approachMovementOwned) + Status = $"Approaching {_targetName} ({_targetDistance:0.0}m)"; + return _approachMovementOwned; + } + + private bool ReadyForBreakableTurn( + in PluginSpellInfo spell, + uint targetObjectId) + { + if (!_settings.UseBreakableTurnTo + || !spell.RequiresTurnTo + || targetObjectId == 0u + || targetObjectId == _host.Automation.Character.ObjectId) + { + StopBreakableTurnMovement(); + return true; + } + + INavigationAutomation navigation = _host.Automation.Navigation; + PluginNavigationSnapshot self = navigation.Snapshot; + if (!self.IsAvailable + || self.IsPortalSpace + || !navigation.TryGetObject( + targetObjectId, + out PluginNavigationObject target)) + { + StopBreakableTurnMovement(); + return true; + } + + float desired = NavigationController.DesiredHeading( + self.Position, + target.Position); + float delta = NavigationController.SignedHeadingDelta( + self.Position.HeadingDegrees, + desired); + if (MathF.Abs(delta) <= 2f) + { + StopBreakableTurnMovement(); + return true; + } + + PluginNavigationCommandStatus result = navigation.SetMovementIntent( + new PluginMovementIntent( + TurnLeft: delta < 0f, + TurnRight: delta > 0f)); + if (result != PluginNavigationCommandStatus.Accepted) + { + StopBreakableTurnMovement(); + return true; + } + _breakableTurnOwned = true; + Status = $"Turning to {_targetName} ({delta:+0.0;-0.0}°)"; + return false; + } + + private void StopBreakableTurnMovement() + { + if (!_breakableTurnOwned) + return; + _host.Automation.Navigation.ClearMovementIntent(); + _breakableTurnOwned = false; + } + + private void StopApproachMovement() + { + if (!_approachMovementOwned) + return; + _ = _host.Automation.Navigation.ClearMovementIntent(); + _approachMovementOwned = false; + } + + private readonly record struct RuleCandidate( + PluginCombatTarget Target, + ResolvedMonsterRule Rule); + + private enum DebuffStartResult + { + Skipped, + Handled, + } + + private sealed class PendingItemDebuff( + CombatDebuffSource source, + uint targetObjectId, + string targetName, + string itemName, + double dispatchedAt, + long itemCompletionRevision, + long physicalCompletionRevision, + PluginCombatMode mode, + float power) + { + public CombatDebuffSource Source { get; } = source; + public uint TargetObjectId { get; } = targetObjectId; + public string TargetName { get; } = targetName; + public string ItemName { get; } = itemName; + public double DispatchedAt { get; } = dispatchedAt; + public long ItemCompletionRevision { get; } = itemCompletionRevision; + public long PhysicalCompletionRevision { get; set; } = + physicalCompletionRevision; + public PluginCombatMode Mode { get; } = mode; + public float Power { get; } = power; + public double? AttackCompletedAt { get; set; } + public bool RecoverySent { get; set; } + public int RecoveryStage { get; set; } + } + + private void ObserveAttackReceipts( + PluginCombatSnapshot combat, + PluginCastCompletion cast) + { + if (combat.CompletionRevision > _observedPhysicalCompletion) + { + _observedPhysicalCompletion = combat.CompletionRevision; + if (_pendingPhysicalTarget != 0u + && combat.CompletionWeenieError == 0u) + { + _failures.RecordSuccessfulAttack( + _pendingPhysicalTarget, + _now, + _settings); + } + _pendingPhysicalTarget = 0u; + } + + if (cast.Revision <= _observedAttackCastCompletion) + return; + _observedAttackCastCompletion = cast.Revision; + if (_pendingAttackSpell == cast.SpellId + && _pendingAttackTarget == cast.TargetObjectId + && cast.IsSuccess) + { + _failures.RecordSuccessfulAttack( + _pendingAttackTarget, + _now, + _settings); + } + if (_pendingAttackSpell == cast.SpellId) + { + _pendingAttackSpell = 0u; + _pendingAttackTarget = 0u; + } + } + + private void DismissGhost(uint objectId) + { + PluginCombatCommandResult result = + _host.Automation.Combat.DismissGhostTarget(objectId); + string suffix = result.Accepted ? "deleted" : "ignored"; + _host.Automation.Chat.PostSystemMessage( + $"[MossTank] Ghost target 0x{objectId:X8} {suffix}."); + if (_targetId == objectId) + { + _host.Automation.Combat.AbortPhysicalAttack(); + ClearTarget(); + } + } +} diff --git a/src/AcDream.Plugins.MossTank/CombatFailureTracker.cs b/src/AcDream.Plugins.MossTank/CombatFailureTracker.cs new file mode 100644 index 00000000..e4509b65 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/CombatFailureTracker.cs @@ -0,0 +1,173 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal enum CombatSuppressionReason +{ + None, + Blacklisted, + Ghost, +} + +/// +/// Implements VTank's three distinct unhittable-target guards. “Ghost” is +/// session-persistent until the object disappears; a normal blacklist expires +/// after the configured timeout. +/// +internal sealed class CombatFailureTracker +{ + private readonly Dictionary _entries = []; + + public IReadOnlyList ObserveTargets( + IReadOnlyList targets, + double now, + CombatSettings settings) + { + var live = new HashSet(); + List? newlyGhosted = null; + foreach (PluginCombatTarget target in targets) + { + live.Add(target.ObjectId); + if (!_entries.TryGetValue(target.ObjectId, out Entry? entry) + || entry.Incarnation != target.Incarnation) + { + _entries[target.ObjectId] = entry = new Entry + { + Incarnation = target.Incarnation, + }; + } + entry.LastSeenAt = now; + if (target.HealthRevision != 0 + && target.HealthRevision != entry.HealthRevision) + { + entry.HealthRevision = target.HealthRevision; + entry.SuccessfulMisses = 0; + entry.SpellStartFailures = 0; + } + + if (entry.BlacklistedUntil <= now) + entry.BlacklistedUntil = 0d; + + if (settings.DeleteGhostMonstersByHealthTracker + && entry.EngagedAt is double engagedAt + && now - engagedAt + >= Math.Max(0d, settings.GhostDeleteHealthTrackerSeconds) + && target.IsHealthKnown + && target.SecondsSinceHealthUpdate + >= Math.Max(0d, settings.GhostDeleteHealthTrackerSeconds)) + { + if (!entry.IsGhost) + { + entry.IsGhost = true; + (newlyGhosted ??= []).Add(target.ObjectId); + } + } + } + + foreach (uint objectId in _entries.Keys.ToArray()) + { + Entry entry = _entries[objectId]; + if (!live.Contains(objectId) + && now - entry.LastSeenAt > Math.Max( + 300d, + settings.BlacklistMonsterTimeoutSeconds)) + { + _entries.Remove(objectId); + } + } + return newlyGhosted ?? (IReadOnlyList)Array.Empty(); + } + + public void BeginEngagement(uint objectId, double now) + { + if (objectId == 0u) + return; + Entry entry = Get(objectId); + entry.EngagedAt ??= now; + } + + public bool RecordSpellDidNotStart( + uint objectId, + CombatSettings settings) + { + if (objectId == 0u || !settings.DeleteGhostMonsters) + return false; + Entry entry = Get(objectId); + entry.SpellStartFailures++; + if (entry.SpellStartFailures + >= Math.Max(1, settings.GhostMonsterSpellAttemptCount)) + { + if (!entry.IsGhost) + { + entry.IsGhost = true; + return true; + } + } + return false; + } + + public void RecordSuccessfulAttack( + uint objectId, + double now, + CombatSettings settings) + { + if (objectId == 0u) + return; + Entry entry = Get(objectId); + if (entry.HealthRevision > entry.AttackHealthRevision) + { + entry.SuccessfulMisses = 0; + return; + } + entry.SuccessfulMisses++; + if (entry.SuccessfulMisses + >= Math.Max(1, settings.BlacklistMonsterAttemptCount)) + { + entry.BlacklistedUntil = now + Math.Max( + 0d, + settings.BlacklistMonsterTimeoutSeconds); + entry.SuccessfulMisses = 0; + } + } + + public void BeginAttack(uint objectId, long healthRevision) + { + if (objectId == 0u) + return; + Entry entry = Get(objectId); + entry.AttackHealthRevision = healthRevision; + } + + public CombatSuppressionReason Reason(uint objectId, double now) + { + if (!_entries.TryGetValue(objectId, out Entry? entry)) + return CombatSuppressionReason.None; + if (entry.IsGhost) + return CombatSuppressionReason.Ghost; + return entry.BlacklistedUntil > now + ? CombatSuppressionReason.Blacklisted + : CombatSuppressionReason.None; + } + + public void Reset() => _entries.Clear(); + + private Entry Get(uint objectId) + { + if (!_entries.TryGetValue(objectId, out Entry? entry)) + _entries[objectId] = entry = new Entry(); + return entry; + } + + private sealed class Entry + { + public ushort Incarnation; + public double LastSeenAt; + public long HealthRevision; + public int SuccessfulMisses; + public int SpellStartFailures; + public long AttackHealthRevision; + public double? EngagedAt; + public double BlacklistedUntil; + public bool IsGhost; + } +} diff --git a/src/AcDream.Plugins.MossTank/CombatItemDebuffPlanner.cs b/src/AcDream.Plugins.MossTank/CombatItemDebuffPlanner.cs new file mode 100644 index 00000000..b7fa3eb9 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/CombatItemDebuffPlanner.cs @@ -0,0 +1,224 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal enum CombatDebuffSourceKind +{ + LearnedSpell, + CasterItem, + ProcWeapon, + Grenade, +} + +internal readonly record struct CombatDebuffSource( + DebuffIdentity Identity, + PluginSpellInfo Spell, + CombatDebuffSourceKind Kind, + uint ItemObjectId, + int SourceSkill, + int ActionOrder) +{ + public bool UsesItem => Kind != CombatDebuffSourceKind.LearnedSpell; +} + +/// +/// Port of official VTank dz.b.CompareTo plus dz.a(MySpell,f7) +/// source discovery. It considers only Items/Consumables profile members, +/// matches by real debuff identity, and gives a direct learned spell the exact +/// final tie-break preference VTank does. +/// +internal static class CombatItemDebuffPlanner +{ + private const uint MeleeWeapon = 0x00000001u; + private const uint MissileWeapon = 0x00000100u; + private const uint Caster = 0x00008000u; + private const uint WarMagicSkill = 34u; + private const uint VoidMagicSkill = 43u; + private const uint AlchemySkill = 38u; + + public static IReadOnlyList Candidates( + MonsterRuleActions actions, + CombatSettings settings, + ICharacterInfo character, + ISpellCatalog spells, + IReadOnlyList items, + Func isDue) + { + ArgumentNullException.ThrowIfNull(actions); + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(character); + ArgumentNullException.ThrowIfNull(spells); + ArgumentNullException.ThrowIfNull(items); + ArgumentNullException.ThrowIfNull(isDue); + + HashSet required = DebuffSpellCatalog.Required(actions); + if (required.Count == 0) + return Array.Empty(); + + var result = new List(); + foreach (PluginSpellInfo spell in spells.KnownCombatSpells) + { + AddIfRequired( + result, + required, + spell, + CombatDebuffSourceKind.LearnedSpell, + 0u, + CurrentSkill(character, spell.School), + isDue); + } + + foreach (PluginInventoryItem item in items) + { + if (settings.CombatItemObjectIds.Contains(item.ObjectId) + || settings.CombatItemNames.Contains(item.Name)) + AddProfileItem(result, required, item, spells, isDue); + if (settings.ConsumableNames.Contains(item.Name)) + AddGrenade(result, required, item, character, spells, isDue); + } + + result.Sort((left, right) => Compare( + left, + right, + settings.DebuffSelectionMethod)); + return result; + } + + private static void AddProfileItem( + ICollection result, + IReadOnlySet required, + PluginInventoryItem item, + ISpellCatalog spells, + Func isDue) + { + if ((item.ItemType & Caster) != 0u + && item.SpellId != 0u + && spells.TryGet(item.SpellId, out PluginSpellInfo casterSpell)) + { + AddIfRequired( + result, + required, + casterSpell, + CombatDebuffSourceKind.CasterItem, + item.ObjectId, + item.ItemSpellcraft, + isDue); + return; + } + + if ((item.ItemType & (MeleeWeapon | MissileWeapon)) == 0u) + return; + foreach (uint spellId in item.AppraisedSpellIds) + { + if (!spells.TryGet(spellId, out PluginSpellInfo proc) + || !proc.IsOffensive + || proc.IsUntargeted + || proc.School is WarMagicSkill or VoidMagicSkill) + { + continue; + } + AddIfRequired( + result, + required, + proc, + CombatDebuffSourceKind.ProcWeapon, + item.ObjectId, + item.ItemSpellcraft, + isDue); + // ga.a uses the first qualifying item spell. + return; + } + } + + private static void AddGrenade( + ICollection result, + IReadOnlySet required, + PluginInventoryItem item, + ICharacterInfo character, + ISpellCatalog spells, + Func isDue) + { + if ((item.ItemType & MissileWeapon) == 0u + || item.CombatUse != 0 + || !GrenadeCatalog.TryGet(item.Name, out GrenadeDefinition grenade) + || CurrentSkill(character, AlchemySkill) < grenade.RequiredAlchemy + || !spells.TryGet(grenade.SpellId, out PluginSpellInfo spell)) + { + return; + } + AddIfRequired( + result, + required, + spell, + CombatDebuffSourceKind.Grenade, + item.ObjectId, + grenade.Spellcraft, + isDue); + } + + private static void AddIfRequired( + ICollection result, + IReadOnlySet required, + PluginSpellInfo spell, + CombatDebuffSourceKind kind, + uint itemObjectId, + int sourceSkill, + Func isDue) + { + if (!DebuffSpellCatalog.TryClassify( + spell, + out DebuffIdentity identity, + out int actionOrder) + || !required.Contains(identity) + || !isDue(identity, spell)) + { + return; + } + result.Add(new CombatDebuffSource( + identity, + spell, + kind, + itemObjectId, + sourceSkill, + actionOrder)); + } + + private static int Compare( + CombatDebuffSource left, + CombatDebuffSource right, + DebuffSelectionMethod selection) + { + if (selection == DebuffSelectionMethod.Skill) + { + int skill = right.SourceSkill.CompareTo(left.SourceSkill); + if (skill != 0) + return skill; + int quality = right.Spell.Quality.CompareTo(left.Spell.Quality); + if (quality != 0) + return quality; + } + else + { + int quality = right.Spell.Quality.CompareTo(left.Spell.Quality); + if (quality != 0) + return quality; + int skill = right.SourceSkill.CompareTo(left.SourceSkill); + if (skill != 0) + return skill; + } + + bool leftDirect = left.Kind == CombatDebuffSourceKind.LearnedSpell; + bool rightDirect = right.Kind == CombatDebuffSourceKind.LearnedSpell; + if (leftDirect != rightDirect) + return leftDirect ? -1 : 1; + int action = left.ActionOrder.CompareTo(right.ActionOrder); + return action != 0 + ? action + : left.ItemObjectId.CompareTo(right.ItemObjectId); + } + + private static int CurrentSkill(ICharacterInfo character, uint skillId) => + character.TryGetSkill(skillId, out PluginSkillInfo skill) + ? checked((int)skill.Current) + : 0; +} diff --git a/src/AcDream.Plugins.MossTank/CombatSettings.cs b/src/AcDream.Plugins.MossTank/CombatSettings.cs new file mode 100644 index 00000000..e7ca4d09 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/CombatSettings.cs @@ -0,0 +1,151 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal enum TargetSelectionMethod +{ + Range, + Angle, + Both, +} + +internal enum DebuffEachFirst +{ + One = 1, + Priority = 2, + All = 3, +} + +internal enum DebuffSelectionMethod +{ + SpellLevel = 1, + Skill = 2, +} + +internal enum PetRangeMode +{ + AttackDistance = 0, + Custom = 1, +} + +internal enum ConsumableCategory +{ + Other, + HealthKit, + HealthFood, + StaminaKit, + StaminaFood, + ManaKit, + ManaFood, + Pea, + AllPeas, + Lockpick, +} + +internal sealed class CombatSettings +{ + /// + /// VTank's EnableCombat profile option. This is deliberately separate + /// from the panel's Run Macro state: a running macro may navigate, loot, + /// buff, or execute Meta rules while combat itself is disabled. + /// + public bool Enabled { get; set; } = true; + /// VTank's hunt-cast skill margin. + public int HuntSkillExcessOverDifficulty { get; set; } = 25; + public float MaximumRange { get; set; } = 5f; + /// + /// Monsters nearer than this are not valid attack targets. VTank applies + /// this before priority and angle/range ranking. + /// + public float MinimumRange { get; set; } + /// + /// VTank's Approach Distance. Zero disables monster approach; otherwise + /// navigation may close a selected target from this range down to + /// . + /// + public float ApproachDistance { get; set; } + public bool IdlePeaceMode { get; set; } + public bool StopMacroOnDeath { get; set; } = true; + public bool JumpOutWandCasting { get; set; } + public bool DoJiggle { get; set; } + public TargetSelectionMethod SelectionMethod { get; set; } = + TargetSelectionMethod.Both; + public float TargetSelectAngleRange { get; set; } = 5f; + public bool TargetLock { get; set; } + public PluginAttackHeight AttackHeight { get; set; } = + PluginAttackHeight.Medium; + public float AttackPower { get; set; } = 0.5f; + public bool AutoAttackPower { get; set; } = true; + public bool UseRecklessness { get; set; } = true; + public double ScanIntervalSeconds { get; set; } = 0.25; + public DebuffEachFirst DebuffEachFirst { get; set; } = DebuffEachFirst.One; + public DebuffSelectionMethod DebuffSelectionMethod { get; set; } = + DebuffSelectionMethod.Skill; + public double DebuffPrecastSeconds { get; set; } = 5d; + public bool SwitchWandsToDebuff { get; set; } + public bool UseArcs { get; set; } = true; + public float SpellRangeFudge { get; set; } = 1f; + public bool UseBreakableTurnTo { get; set; } = true; + public bool UseProjectileAwareness { get; set; } = true; + public float CollisionProjectileRadius { get; set; } = 0.4f; + public float CollisionStepDistance { get; set; } = 0.7f; + public bool ShowCollisionDebug { get; set; } + public int MaximumCollisionChecksPerTick { get; set; } = 500; + public float ArcRange { get; set; } = 5f; + public float RingDistance { get; set; } = 5f; + public int MinimumRingTargets { get; set; } = 4; + public bool DeleteGhostMonsters { get; set; } = true; + public int GhostMonsterSpellAttemptCount { get; set; } = 200; + public int BlacklistMonsterAttemptCount { get; set; } = 4; + public double BlacklistMonsterTimeoutSeconds { get; set; } = 120d; + public bool DeleteGhostMonstersByHealthTracker { get; set; } = true; + public double GhostDeleteHealthTrackerSeconds { get; set; } = 30d; + public bool SummonPets { get; set; } = true; + public PetRangeMode PetRangeMode { get; set; } = PetRangeMode.AttackDistance; + public float PetCustomRange { get; set; } = 5f; + public int PetMonsterDensity { get; set; } = 1; + public int PetRefillCountIdle { get; set; } = 3; + public int PetRefillCountNormal { get; set; } = 1; + public bool AllowDebuffFallback { get; set; } + public int UseSpecialAmmo { get; set; } + public bool WhoYouGonnaCall { get; set; } = true; + public bool AutoFellowManagement { get; set; } = true; + public string BlacklistedSpellComponents { get; set; } = string.Empty; + /// + /// Runtime object ids resolved from VTank's Items profile. Debuff lenses + /// and cast-on-strike weapons are never taken from arbitrary inventory. + /// + public ISet CombatItemObjectIds { get; } = new HashSet(); + public ISet CombatItemNames { get; } = + new HashSet(StringComparer.Ordinal); + /// Exact names enabled in VTank's Consumables profile. + public ISet ConsumableNames { get; } = + new HashSet(StringComparer.Ordinal); + public IDictionary ConsumableCategories { get; } = + new Dictionary(StringComparer.Ordinal); + public IList Rules { get; } = + new List { new("DEFAULT", 0) }; + + public ResolvedMonsterRule ResolveRule(PluginCombatTarget target) + { + var context = new MonsterExpressionContext( + target.Name, + target.WeenieClassId, + target.SpeciesName, + target.MaximumHealth, + target.Distance, + target.HasShield, + MetaState, + ResolveSetting); + return MonsterRuleResolver.Resolve(Rules, context); + } + + public string MetaState { get; set; } = "Default"; + public IDictionary DynamicSettings { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + private MonsterValue? ResolveSetting(string name) => + DynamicSettings.TryGetValue(name, out MonsterValue value) + ? value + : null; +} diff --git a/src/AcDream.Plugins.MossTank/Crafting.cs b/src/AcDream.Plugins.MossTank/Crafting.cs new file mode 100644 index 00000000..65abec4f --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Crafting.cs @@ -0,0 +1,649 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal readonly record struct CraftingPlan( + VtankCraftRecipe Recipe, + uint FirstObjectId, + uint SecondObjectId, + string DesiredResult) +{ + public bool RequiresSplitFirstStack { get; init; } + public uint SplitContainerObjectId { get; init; } +} + +internal static class ConsumableClassifier +{ + private const uint HealingKitPublicFlag = 0x00010000u; + private const uint LockpickPublicFlag = 0x00020000u; + + public static ConsumableCategory Classify(in PluginInventoryItem item) + { + if (item.Name.Equals(CraftingPlanner.AllPeas, StringComparison.Ordinal)) + return ConsumableCategory.AllPeas; + if (item.Name.EndsWith(" Pea", StringComparison.Ordinal)) + return ConsumableCategory.Pea; + if ((item.PublicFlags & LockpickPublicFlag) != 0u) + return ConsumableCategory.Lockpick; + if ((item.PublicFlags & HealingKitPublicFlag) != 0u) + return KitCategory(item.Name); + return item.BoosterVital switch + { + 2 => ConsumableCategory.HealthFood, + 4 => ConsumableCategory.StaminaFood, + 6 => ConsumableCategory.ManaFood, + _ => ClassifyName(item.Name), + }; + } + + public static ConsumableCategory ClassifyName(string name) + { + if (name.Equals(CraftingPlanner.AllPeas, StringComparison.Ordinal)) + return ConsumableCategory.AllPeas; + if (name.EndsWith(" Pea", StringComparison.Ordinal)) + return ConsumableCategory.Pea; + return name.EndsWith(" Kit", StringComparison.Ordinal) + ? KitCategory(name) + : ConsumableCategory.Other; + } + + private static ConsumableCategory KitCategory(string name) => name switch + { + "Medicated Stamina Kit" or "Eternal Stamina Kit" + or "Greater Stamina Kit" or "Lesser Stamina Kit" => + ConsumableCategory.StaminaKit, + "Medicated Mana Kit" or "Eternal Mana Kit" + or "Greater Mana Kit" or "Lesser Mana Kit" => + ConsumableCategory.ManaKit, + _ => ConsumableCategory.HealthKit, + }; +} + +internal static class CraftingPlanner +{ + public const string AllPeas = "[All Peas]"; + + public static CraftingPlan? Plan( + IReadOnlyList inventory, + IEnumerable desiredResults, + ICharacterInfo character, + int desiredCount = 1, + int arrowheadFletchDifficultyExcess = 10) + { + ArgumentNullException.ThrowIfNull(inventory); + ArgumentNullException.ThrowIfNull(desiredResults); + ArgumentNullException.ThrowIfNull(character); + var counts = inventory + .GroupBy(static item => item.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + static group => group.Key, + static group => group.Sum(item => Math.Max(1, item.StackSize)), + StringComparer.OrdinalIgnoreCase); + foreach (string desired in desiredResults + .Where(static name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(static name => name, StringComparer.OrdinalIgnoreCase)) + { + if (counts.GetValueOrDefault(desired) >= Math.Max(1, desiredCount)) + continue; + CraftingPlan? plan = FindStep( + desired, + desired, + inventory, + character, + counts, + new HashSet(StringComparer.OrdinalIgnoreCase), + arrowheadFletchDifficultyExcess); + if (plan is not null) + return plan; + } + return null; + } + + public static CraftingPlan? PlanPeaSplit( + IReadOnlyList inventory, + ISet consumableProfile, + int minimumComponentCount) + { + ArgumentNullException.ThrowIfNull(inventory); + ArgumentNullException.ThrowIfNull(consumableProfile); + int minimum = Math.Max(0, minimumComponentCount); + if (minimum == 0) + return null; + PluginInventoryItem tool = Find(inventory, "Splitting Tool"); + if (tool.ObjectId == 0u) + return null; + bool allPeas = consumableProfile.Contains(AllPeas); + var counts = inventory + .GroupBy(static item => item.Name, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + static group => group.Key, + static group => group.Sum(item => Math.Max(1, item.StackSize)), + StringComparer.OrdinalIgnoreCase); + foreach (VtankCraftRecipe recipe in VtankCraftDatabase.Recipes) + { + if (!recipe.FirstItem.Equals("Splitting Tool", StringComparison.Ordinal) + || !recipe.SecondItem.EndsWith(" Pea", StringComparison.Ordinal) + || (!allPeas && !consumableProfile.Contains(recipe.SecondItem)) + || counts.GetValueOrDefault(recipe.ResultItem) >= minimum) + { + continue; + } + PluginInventoryItem pea = Find(inventory, recipe.SecondItem); + if (pea.ObjectId == 0u) + continue; + return new CraftingPlan( + recipe, + tool.ObjectId, + pea.ObjectId, + recipe.ResultItem); + } + return null; + } + + private static CraftingPlan? FindStep( + string result, + string desiredResult, + IReadOnlyList inventory, + ICharacterInfo character, + IReadOnlyDictionary counts, + HashSet visiting, + int arrowheadFletchDifficultyExcess) + { + if (!visiting.Add(result)) + return null; + try + { + foreach (VtankCraftRecipe recipe in VtankCraftDatabase.ForResult(result)) + { + if (!HasRequiredSkill( + character, + recipe.RequiredSkill, + recipe.Difficulty, + arrowheadFletchDifficultyExcess)) + continue; + + PluginInventoryItem first = Find(inventory, recipe.FirstItem); + if (first.ObjectId == 0u) + { + CraftingPlan? prerequisite = FindStep( + recipe.FirstItem, + desiredResult, + inventory, + character, + counts, + visiting, + arrowheadFletchDifficultyExcess); + if (prerequisite is not null) + return prerequisite; + continue; + } + + PluginInventoryItem second = Find( + inventory, + recipe.SecondItem, + excludedObjectId: recipe.FirstItem.Equals( + recipe.SecondItem, + StringComparison.OrdinalIgnoreCase) + ? first.ObjectId + : 0u); + if (second.ObjectId == 0u) + { + if (recipe.FirstItem.Equals( + recipe.SecondItem, + StringComparison.OrdinalIgnoreCase) + && first.StackSize >= 2) + { + return new CraftingPlan( + recipe, + first.ObjectId, + 0u, + desiredResult) + { + RequiresSplitFirstStack = true, + SplitContainerObjectId = first.ContainerObjectId, + }; + } + CraftingPlan? prerequisite = FindStep( + recipe.SecondItem, + desiredResult, + inventory, + character, + counts, + visiting, + arrowheadFletchDifficultyExcess); + if (prerequisite is not null) + return prerequisite; + continue; + } + + return new CraftingPlan( + recipe, + first.ObjectId, + second.ObjectId, + desiredResult); + } + return null; + } + finally + { + visiting.Remove(result); + } + } + + private static PluginInventoryItem Find( + IReadOnlyList inventory, + string name, + uint excludedObjectId = 0u) + { + foreach (PluginInventoryItem item in inventory) + { + if (item.ObjectId != excludedObjectId + && item.StackSize > 0 + && item.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) + { + return item; + } + } + return default; + } + + private static bool HasRequiredSkill( + ICharacterInfo character, + uint requiredSkill, + int difficulty, + int arrowheadFletchDifficultyExcess) + { + if (requiredSkill == 0u) + return true; + if (!character.TryGetSkill(requiredSkill, out PluginSkillInfo skill) + || skill.Training is not (PluginSkillTraining.Trained + or PluginSkillTraining.Specialized)) + { + return false; + } + return requiredSkill != 37u + || skill.Current >= Math.Max(0, difficulty) + + arrowheadFletchDifficultyExcess; + } +} + +internal sealed class CraftingController +{ + private const double SplitTimeoutSeconds = 10d; + + private readonly IPluginHost _host; + private readonly InventorySettings _settings; + private readonly CombatSettings _profiles; + private CraftingPlan? _pending; + private CraftingPlan? _pendingSplit; + private long _observedCompletion; + private long _observedInventoryCompletion; + private double _untilScan; + private double _untilCriticalScan; + private double _untilIdleScan; + private double _splitElapsed; + private bool _splitAcknowledged; + + public CraftingController( + IPluginHost host, + InventorySettings settings, + CombatSettings profiles) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _profiles = profiles ?? throw new ArgumentNullException(nameof(profiles)); + } + + public string Status { get; private set; } = "AutoCraft idle"; + + /// + /// Immediate VTank subsystem request, used by ammunition selection. This + /// bypasses the general AutoCraftItems toggle just as bv.cs does, while + /// still using the one canonical crafting transaction state machine. + /// + public bool Request(string resultName, int desiredCount = 1) + { + if (string.IsNullOrWhiteSpace(resultName) + || _pending is not null + || _pendingSplit is not null + || !_host.Automation.IsAvailable) + { + return false; + } + IItemAutomation items = _host.Automation.Items; + if (!items.IsAvailable || items.IsBusy) + return false; + CraftingPlan? plan = CraftingPlanner.Plan( + items.CaptureOwnedItems(), + [resultName], + _host.Automation.Character, + desiredCount, + _settings.ArrowheadFletchDifficultyExcess); + return plan is { } next && Start(items, next); + } + + public bool CanRequest(string resultName, int desiredCount = 1) + { + if (string.IsNullOrWhiteSpace(resultName) + || !_host.Automation.IsAvailable + || !_host.Automation.Items.IsAvailable) + { + return false; + } + return CraftingPlanner.Plan( + _host.Automation.Items.CaptureOwnedItems(), + [resultName], + _host.Automation.Character, + desiredCount, + _settings.ArrowheadFletchDifficultyExcess) + is not null; + } + + public bool TickCritical(double elapsedSeconds, bool canAct) + { + IItemAutomation items = _host.Automation.Items; + ObserveCompletion(items); + if (ObserveSplitCompletion(items, elapsedSeconds)) + return true; + if (_pending is not null) + { + if (items.IsBusy) + return true; + _pending = null; + } + if (!canAct + || !_settings.AutoCraftItems + || !_host.Automation.IsAvailable + || !items.IsAvailable + || items.IsBusy) + { + return false; + } + _untilCriticalScan -= Math.Max(0d, elapsedSeconds); + if (_untilCriticalScan > 0d) + return false; + _untilCriticalScan = Math.Max(0.1d, _settings.ScanIntervalSeconds); + IReadOnlyList inventory = items.CaptureOwnedItems(); + CraftingPlan? plan = _settings.SplitPeas + ? CraftingPlanner.PlanPeaSplit( + inventory, + _profiles.ConsumableNames, + _settings.CriticalComponentMinimum) + : null; + plan ??= PlanCategoryCraft( + inventory, + idleCounts: false); + return plan is { } next && Start(items, next); + } + + public bool Tick(double elapsedSeconds, bool canAct) + { + IItemAutomation items = _host.Automation.Items; + ObserveCompletion(items); + if (ObserveSplitCompletion(items, elapsedSeconds)) + return true; + if (_pending is not null) + { + if (items.IsBusy) + return true; + _pending = null; + } + if (!canAct + || !_settings.AutoCraftItems + || !_host.Automation.IsAvailable + || !items.IsAvailable + || items.IsBusy) + { + return false; + } + + _untilScan -= Math.Max(0d, elapsedSeconds); + if (_untilScan > 0d) + return false; + _untilScan = Math.Max(0.1d, _settings.ScanIntervalSeconds); + IReadOnlyList inventory = items.CaptureOwnedItems(); + CraftingPlan? plan = _settings.SplitPeas + ? CraftingPlanner.PlanPeaSplit( + inventory, + _profiles.ConsumableNames, + _settings.NormalComponentMinimum) + : null; + plan ??= CraftingPlanner.Plan( + inventory, + _profiles.ConsumableNames + .Concat(_profiles.CombatItemNames) + .Where(static name => + !name.Equals(CraftingPlanner.AllPeas, StringComparison.Ordinal) + && !name.EndsWith(" Pea", StringComparison.Ordinal)), + _host.Automation.Character, + arrowheadFletchDifficultyExcess: + _settings.ArrowheadFletchDifficultyExcess); + if (plan is not { } next) + { + Status = "AutoCraft idle"; + return false; + } + + return Start(items, next); + } + + public bool TickIdle(double elapsedSeconds, bool canAct) + { + IItemAutomation items = _host.Automation.Items; + ObserveCompletion(items); + if (ObserveSplitCompletion(items, elapsedSeconds)) + return true; + if (_pending is not null) + { + if (items.IsBusy) + return true; + _pending = null; + } + if (!canAct + || !_settings.AutoCraftItems + || !_host.Automation.IsAvailable + || !items.IsAvailable + || items.IsBusy) + { + return false; + } + _untilIdleScan -= Math.Max(0d, elapsedSeconds); + if (_untilIdleScan > 0d) + return false; + _untilIdleScan = Math.Max(0.1d, _settings.ScanIntervalSeconds); + IReadOnlyList inventory = items.CaptureOwnedItems(); + CraftingPlan? plan = _settings.SplitPeas + ? CraftingPlanner.PlanPeaSplit( + inventory, + _profiles.ConsumableNames, + _settings.IdleComponentMinimum) + : null; + plan ??= PlanCategoryCraft(inventory, idleCounts: true); + return plan is { } next && Start(items, next); + } + + private CraftingPlan? PlanCategoryCraft( + IReadOnlyList inventory, + bool idleCounts) + { + foreach (string name in _profiles.ConsumableNames + .OrderBy(static name => name, StringComparer.Ordinal)) + { + ConsumableCategory category = _profiles.ConsumableCategories + .TryGetValue(name, out ConsumableCategory stored) + ? stored + : ConsumableClassifier.ClassifyName(name); + int desired = idleCounts ? IdleCount(category) : category switch + { + ConsumableCategory.HealthKit + or ConsumableCategory.HealthFood + or ConsumableCategory.StaminaKit + or ConsumableCategory.StaminaFood + or ConsumableCategory.ManaKit + or ConsumableCategory.ManaFood => 1, + _ => 0, + }; + if (desired <= 0) + continue; + CraftingPlan? plan = CraftingPlanner.Plan( + inventory, + [name], + _host.Automation.Character, + desired, + _settings.ArrowheadFletchDifficultyExcess); + if (plan is not null) + return plan; + } + return null; + } + + private int IdleCount(ConsumableCategory category) => category switch + { + ConsumableCategory.HealthKit => _settings.IdleHealthKitCount, + ConsumableCategory.StaminaKit => _settings.IdleStaminaKitCount, + ConsumableCategory.ManaKit => _settings.IdleManaKitCount, + ConsumableCategory.HealthFood => _settings.IdleHealthFoodCount, + ConsumableCategory.StaminaFood => _settings.IdleStaminaFoodCount, + ConsumableCategory.ManaFood => _settings.IdleManaFoodCount, + _ => 0, + }; + + private bool Start(IItemAutomation items, CraftingPlan next) + { + if (next.RequiresSplitFirstStack) + { + long completionBefore = items.LastInventoryCompletion.Revision; + PluginItemCommandResult split = items.MoveToContainer( + next.FirstObjectId, + next.SplitContainerObjectId, + amount: 1u); + if (!split.Accepted) + { + Status = $"AutoCraft split waiting: {split.Status}"; + return split.Status == PluginItemCommandStatus.Busy; + } + _pendingSplit = next; + _observedInventoryCompletion = completionBefore; + _splitElapsed = 0d; + _splitAcknowledged = false; + Status = $"Splitting {next.Recipe.FirstItem} for crafting"; + return true; + } + PluginItemCommandResult result = items.Apply( + next.FirstObjectId, + next.SecondObjectId); + if (!result.Accepted) + { + Status = $"AutoCraft waiting: {result.Status}"; + return result.Status == PluginItemCommandStatus.Busy; + } + _pending = next; + Status = $"Crafting {next.Recipe.ResultItem}"; + return true; + } + + public void Reset() + { + _pending = null; + _pendingSplit = null; + _untilScan = 0d; + _untilCriticalScan = 0d; + _untilIdleScan = 0d; + _splitElapsed = 0d; + _splitAcknowledged = false; + Status = "AutoCraft idle"; + } + + private void ObserveCompletion(IItemAutomation items) + { + PluginItemUseCompletion completion = items.LastCompletion; + if (completion.Revision == 0 || completion.Revision == _observedCompletion) + return; + _observedCompletion = completion.Revision; + if (_pending is not { } pending + || completion.SourceObjectId != pending.FirstObjectId) + { + return; + } + Status = completion.IsSuccess + ? $"Crafted {pending.Recipe.ResultItem}" + : $"Craft failed (0x{completion.WeenieError:X})"; + _pending = null; + _untilScan = 0d; + _untilCriticalScan = 0d; + _untilIdleScan = 0d; + } + + private bool ObserveSplitCompletion( + IItemAutomation items, + double elapsedSeconds) + { + if (_pendingSplit is not { } splitPlan) + return false; + + _splitElapsed += Math.Max(0d, elapsedSeconds); + PluginInventoryCompletion completion = items.LastInventoryCompletion; + if (completion.Revision != 0 + && completion.Revision != _observedInventoryCompletion) + { + _observedInventoryCompletion = completion.Revision; + if (completion.SourceObjectId == splitPlan.FirstObjectId) + { + if (!completion.IsSuccess) + { + Status = $"AutoCraft split failed (0x{completion.WeenieError:X})"; + ClearPendingSplit(); + return true; + } + _splitAcknowledged = true; + } + } + + if (_splitAcknowledged && TryStartAfterSplit(items, splitPlan)) + return true; + if (_splitElapsed < SplitTimeoutSeconds) + { + Status = _splitAcknowledged + ? "AutoCraft waiting for split inventory" + : $"Splitting {splitPlan.Recipe.FirstItem} for crafting"; + return true; + } + + Status = "AutoCraft split timed out"; + ClearPendingSplit(); + return true; + } + + private bool TryStartAfterSplit( + IItemAutomation items, + CraftingPlan splitPlan) + { + PluginInventoryItem[] inputs = items.CaptureOwnedItems() + .Where(item => item.Name.Equals( + splitPlan.Recipe.FirstItem, + StringComparison.OrdinalIgnoreCase)) + .OrderBy(static item => item.ObjectId) + .ToArray(); + if (inputs.Length < 2) + return false; + CraftingPlan ready = splitPlan with + { + FirstObjectId = inputs[0].ObjectId, + SecondObjectId = inputs[1].ObjectId, + RequiresSplitFirstStack = false, + }; + ClearPendingSplit(); + return Start(items, ready); + } + + private void ClearPendingSplit() + { + _pendingSplit = null; + _splitElapsed = 0d; + _splitAcknowledged = false; + _untilScan = Math.Max(0.1d, _settings.ScanIntervalSeconds); + _untilCriticalScan = Math.Max(0.1d, _settings.ScanIntervalSeconds); + _untilIdleScan = Math.Max(0.1d, _settings.ScanIntervalSeconds); + } +} diff --git a/src/AcDream.Plugins.MossTank/DebuffScheduler.cs b/src/AcDream.Plugins.MossTank/DebuffScheduler.cs new file mode 100644 index 00000000..4a30bb52 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/DebuffScheduler.cs @@ -0,0 +1,400 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal readonly record struct DebuffIdentity( + MonsterActionFlags Flag, + MonsterDamageType DamageType); + +internal readonly record struct DebuffChoice( + DebuffIdentity Identity, + PluginSpellInfo Spell, + int ActionOrder); + +/// +/// Converts retail spell-table data into VTank's Monsters-column vocabulary. +/// Names are the stable retail identities VTank exposed to users; no host-side +/// combat policy leaks into the plugin API. +/// +internal sealed class DebuffSpellCatalog +{ + private static readonly (MonsterActionFlags Flag, int Order)[] OrderedFlags = + [ + (MonsterActionFlags.Fester, 0), + (MonsterActionFlags.Broadside, 1), + (MonsterActionFlags.GravityWell, 2), + (MonsterActionFlags.Imperil, 3), + (MonsterActionFlags.Yield, 4), + (MonsterActionFlags.Vulnerability, 5), + (MonsterActionFlags.WeakeningCurse, 6), + (MonsterActionFlags.FesteringCurse, 7), + (MonsterActionFlags.Corruption, 8), + (MonsterActionFlags.DestructiveCurse, 9), + (MonsterActionFlags.Corrosion, 10), + ]; + + private readonly DebuffChoice[] _choices; + + private DebuffSpellCatalog(DebuffChoice[] choices) => _choices = choices; + + public static DebuffSpellCatalog Build(IReadOnlyList spells) + { + ArgumentNullException.ThrowIfNull(spells); + var choices = new List(); + foreach (PluginSpellInfo spell in spells) + { + if (!TryClassify(spell, out DebuffIdentity identity, out int order)) + continue; + choices.Add(new DebuffChoice(identity, spell, order)); + } + return new DebuffSpellCatalog([.. choices]); + } + + public IReadOnlyList Candidates( + MonsterRuleActions actions, + DebuffSelectionMethod selection, + ICharacterInfo character, + Func isDue) + { + ArgumentNullException.ThrowIfNull(actions); + ArgumentNullException.ThrowIfNull(character); + ArgumentNullException.ThrowIfNull(isDue); + + HashSet required = Required(actions); + if (required.Count == 0 || _choices.Length == 0) + return Array.Empty(); + + var candidates = new List(); + foreach (DebuffChoice choice in _choices) + { + if (required.Contains(choice.Identity) + && isDue(choice.Identity, choice.Spell)) + { + candidates.Add(choice); + } + } + + candidates.Sort((left, right) => Compare( + left, right, selection, character)); + return candidates; + } + + public bool HasKnownRequirement(MonsterRuleActions actions) + { + HashSet required = Required(actions); + foreach (DebuffChoice choice in _choices) + { + if (required.Contains(choice.Identity)) + return true; + } + return false; + } + + private static int Compare( + DebuffChoice left, + DebuffChoice right, + DebuffSelectionMethod selection, + ICharacterInfo character) + { + if (selection == DebuffSelectionMethod.Skill) + { + uint leftSkill = Skill(character, left.Spell.School); + uint rightSkill = Skill(character, right.Spell.School); + int skill = rightSkill.CompareTo(leftSkill); + if (skill != 0) + return skill; + } + + int tier = right.Spell.Tier.CompareTo(left.Spell.Tier); + if (tier != 0) + return tier; + int difficulty = right.Spell.Difficulty.CompareTo(left.Spell.Difficulty); + if (difficulty != 0) + return difficulty; + int action = left.ActionOrder.CompareTo(right.ActionOrder); + return action != 0 + ? action + : left.Spell.SpellId.CompareTo(right.Spell.SpellId); + } + + private static uint Skill(ICharacterInfo character, uint skillId) => + character.TryGetSkill(skillId, out PluginSkillInfo skill) + ? skill.Current + : 0u; + + internal static HashSet Required(MonsterRuleActions actions) + { + var required = new HashSet(); + foreach ((MonsterActionFlags flag, _) in OrderedFlags) + { + if ((actions.Flags & flag) == 0) + continue; + MonsterDamageType damage = flag == MonsterActionFlags.Vulnerability + ? actions.DamageType + : MonsterDamageType.Auto; + required.Add(new DebuffIdentity(flag, damage)); + } + + if ((actions.Flags & MonsterActionFlags.Vulnerability) != 0 + && actions.ExtraVulnerability != MonsterDamageType.Auto) + { + required.Add(new DebuffIdentity( + MonsterActionFlags.Vulnerability, + actions.ExtraVulnerability)); + } + return required; + } + + internal static bool TryClassify( + PluginSpellInfo spell, + out DebuffIdentity identity, + out int order) + { + string name = Normalize(spell.Name); + MonsterActionFlags flag; + MonsterDamageType damage = MonsterDamageType.Auto; + + if (name.StartsWith("Fester Other", StringComparison.OrdinalIgnoreCase)) + flag = MonsterActionFlags.Fester; + else if (name.StartsWith("Broadside of a Barn", StringComparison.OrdinalIgnoreCase)) + flag = MonsterActionFlags.Broadside; + else if (name.StartsWith("Gravity Well", StringComparison.OrdinalIgnoreCase)) + flag = MonsterActionFlags.GravityWell; + else if (name.StartsWith("Imperil Other", StringComparison.OrdinalIgnoreCase)) + flag = MonsterActionFlags.Imperil; + else if (name.StartsWith("Magic Yield Other", StringComparison.OrdinalIgnoreCase)) + flag = MonsterActionFlags.Yield; + else if (name.Contains(" Vulnerability Other", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("Vulnerability Other", StringComparison.OrdinalIgnoreCase) + || IsClassicLure(name)) + { + flag = MonsterActionFlags.Vulnerability; + damage = DamageFromName(name); + } + else if (name.StartsWith("Weakening Curse", StringComparison.OrdinalIgnoreCase)) + flag = MonsterActionFlags.WeakeningCurse; + else if (name.StartsWith("Festering Curse", StringComparison.OrdinalIgnoreCase)) + flag = MonsterActionFlags.FesteringCurse; + else if (name.StartsWith("Corruption", StringComparison.OrdinalIgnoreCase)) + flag = MonsterActionFlags.Corruption; + else if (name.StartsWith("Destructive Curse", StringComparison.OrdinalIgnoreCase)) + flag = MonsterActionFlags.DestructiveCurse; + else if (name.StartsWith("Corrosion", StringComparison.OrdinalIgnoreCase)) + flag = MonsterActionFlags.Corrosion; + else + { + identity = default; + order = int.MaxValue; + return false; + } + + order = Array.FindIndex( + OrderedFlags, + entry => entry.Flag == flag); + if (order < 0) + order = int.MaxValue; + identity = new DebuffIdentity(flag, damage); + return true; + } + + private static string Normalize(string name) + { + const string incantation = "Incantation of "; + return name.StartsWith(incantation, StringComparison.OrdinalIgnoreCase) + ? name[incantation.Length..] + : name; + } + + /// + /// Retail's levels I-VII vulnerability line uses the older * Lure names. + /// Do not confuse it with the distinct Lure Blade item-enchantment line. + /// + private static bool IsClassicLure(string name) => + name.StartsWith("Acid Lure", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("Blade Lure", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("Bludgeon Lure", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("Flame Lure", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("Frost Lure", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("Lightning Lure", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("Piercing Lure", StringComparison.OrdinalIgnoreCase); + + internal static MonsterDamageType DamageFromName(string name) + { + if (name.Contains("Blade", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Slash; + if (name.Contains("Piercing", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Pierce; + if (name.Contains("Bludgeon", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Bludgeon; + if (name.Contains("Cold", StringComparison.OrdinalIgnoreCase) + || name.Contains("Frost", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Cold; + if (name.Contains("Fire", StringComparison.OrdinalIgnoreCase) + || name.Contains("Flame", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Fire; + if (name.Contains("Acid", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Acid; + if (name.Contains("Lightning", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Electric; + if (name.Contains("Nether", StringComparison.OrdinalIgnoreCase)) + return MonsterDamageType.Nether; + return MonsterDamageType.Auto; + } +} + +/// +/// Session-local VTank spell tracker. A debuff becomes active only after the +/// host publishes its matching server UseDone receipt. +/// +internal sealed class DebuffTracker +{ + private readonly Dictionary<(uint Target, DebuffIdentity Identity), Applied> _applied = []; + private Pending? _pending; + private long _observedCompletionRevision; + + public bool HasPending => _pending is not null; + public string PendingName => _pending?.Spell.Name ?? string.Empty; + public uint PendingTarget => _pending?.TargetObjectId ?? 0u; + + public bool IsDue( + uint targetObjectId, + DebuffIdentity identity, + PluginSpellInfo spell, + double now, + double precastSeconds) + { + if (!_applied.TryGetValue((targetObjectId, identity), out Applied applied)) + return true; + if (applied.SpellId != spell.SpellId && spell.Tier > applied.Tier) + return true; + double lead = spell.IsDamageOverTime ? 0d : Math.Max(0d, precastSeconds); + return now >= applied.ExpiresAt - lead; + } + + public void Begin( + uint targetObjectId, + DebuffIdentity identity, + PluginSpellInfo spell, + double now, + long completionRevision) + { + _observedCompletionRevision = Math.Max( + _observedCompletionRevision, + completionRevision); + _pending = new Pending(targetObjectId, identity, spell, now); + } + + public DebuffCompletion Observe( + PluginCastCompletion completion, + double now) + { + if (completion.Revision <= _observedCompletionRevision) + return default; + _observedCompletionRevision = completion.Revision; + if (_pending is not { } pending + || pending.Spell.SpellId != completion.SpellId + || pending.TargetObjectId != completion.TargetObjectId) + { + return default; + } + + _pending = null; + if (!completion.IsSuccess) + { + return new DebuffCompletion( + Completed: true, + Succeeded: false, + pending.Spell.Name, + completion.WeenieError); + } + + double duration = Math.Max(0d, pending.Spell.DurationSeconds); + _applied[(pending.TargetObjectId, pending.Identity)] = new Applied( + pending.Spell.SpellId, + pending.Spell.Tier, + now + duration); + return new DebuffCompletion( + Completed: true, + Succeeded: true, + pending.Spell.Name, + 0u); + } + + public bool ExpirePending(double now, double timeoutSeconds = 15d) + { + if (_pending is not { } pending + || now - pending.DispatchedAt < timeoutSeconds) + { + return false; + } + _pending = null; + return true; + } + + public void RecordApplied( + uint targetObjectId, + DebuffIdentity identity, + PluginSpellInfo spell, + double now) + { + double duration = Math.Max(0d, spell.DurationSeconds); + _applied[(targetObjectId, identity)] = new Applied( + spell.SpellId, + spell.Tier, + now + duration); + } + + /// + /// VTank's /vt fakeimp records Gossamer Flesh locally for 3,000 + /// seconds. It is deliberately stronger than every learnable Imperil tier + /// so the debug marker remains authoritative for its requested duration. + /// + public void RecordFakeImperil(uint targetObjectId, double now) + { + const uint gossamerFlesh = 0x081Au; + const double durationSeconds = 3000d; + _applied[(targetObjectId, new DebuffIdentity( + MonsterActionFlags.Imperil, + MonsterDamageType.Auto))] = new Applied( + gossamerFlesh, + int.MaxValue, + now + durationSeconds); + } + + public void ClearPending() => _pending = null; + + public void RetainTargets(IReadOnlySet liveTargets) + { + if (_applied.Count == 0) + return; + foreach ((uint Target, DebuffIdentity Identity) key in _applied.Keys.ToArray()) + { + if (!liveTargets.Contains(key.Target)) + _applied.Remove(key); + } + } + + public void Reset() + { + _applied.Clear(); + _pending = null; + _observedCompletionRevision = 0; + } + + private readonly record struct Pending( + uint TargetObjectId, + DebuffIdentity Identity, + PluginSpellInfo Spell, + double DispatchedAt); + + private readonly record struct Applied( + uint SpellId, + int Tier, + double ExpiresAt); +} + +internal readonly record struct DebuffCompletion( + bool Completed, + bool Succeeded, + string SpellName, + uint WeenieError); diff --git a/src/AcDream.Plugins.MossTank/DispelController.cs b/src/AcDream.Plugins.MossTank/DispelController.cs new file mode 100644 index 00000000..c81631c2 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/DispelController.cs @@ -0,0 +1,420 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// VTank's post-buff dispel rules from c8.cs, cx.cs and af.cs. Policy lives in +/// the plugin; the host contributes only canonical spell, item, mode and +/// completion operations. +/// +internal sealed class DispelController +{ + private const uint EradicateLifeMagicSelf = + (uint)SpellId.EradicateLifeMagicSelf; + private const double ActionTimeoutSeconds = 15d; + private const float AllyDispelRangeMeters = 5f; + private const uint CreatureEnchantmentSkill = 31u; + private const uint ArcaneLoreSkill = 14u; + private const uint DispelProtectionSpell = 3179u; + + private static readonly string[] HighDifficultyItems = + [ + "Rune of Dispel", + "Society Gem of Dispelling", + "Black Market Gem of Dispelling", + ]; + + private static readonly string[] NormalDifficultyItems = + [ + "Rune of Dispel", + "Chocolate Gromnie", + "Condensed Dispel Potion", + "Gem of Stillness", + ]; + + private readonly IPluginHost _host; + private readonly VitalSettings _settings; + private Pending? _pending; + private double _pendingSeconds; + private double _retryDelay; + + public DispelController(IPluginHost host, VitalSettings settings) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + } + + public string Status { get; private set; } = "Dispel idle"; + + public bool Tick(double elapsedSeconds, bool canAct) + { + double elapsed = Math.Max(0d, elapsedSeconds); + _retryDelay = Math.Max(0d, _retryDelay - elapsed); + if (ObservePending(elapsed)) + return true; + + IAutomationSurface automation = _host.Automation; + if (!canAct + || _retryDelay > 0d + || !_host.Automation.IsAvailable + || (!_settings.CastDispelSelf + && !_settings.UseDispelItems + && !_settings.UseDispelDrum) + || automation.Magic.IsCasting + || automation.Items.IsBusy) + { + return false; + } + + if (_settings.CastDispelSelf + && TryStartSelfDispel(automation)) + { + return true; + } + if (_settings.UseDispelItems + && TrySelectDispelItem(automation, out PluginInventoryItem item)) + { + long revision = automation.Items.LastCompletion.Revision; + PluginItemCommandResult result = automation.Items.Use(item.ObjectId); + if (result.Accepted) + { + _pending = new Pending( + DispelSource.Item, + item.ObjectId, + item.Name, + revision); + _pendingSeconds = 0d; + Status = $"Using {item.Name}"; + return true; + } + Status = $"Waiting to use {item.Name}"; + return result.Status == PluginItemCommandStatus.Busy; + } + if (_settings.UseDispelDrum && TryStartAllyDispel(automation)) + return true; + + Status = "Dispel idle"; + return false; + } + + public void Reset() + { + _pending = null; + _pendingSeconds = 0d; + _retryDelay = 0d; + Status = "Dispel idle"; + } + + private bool TryStartSelfDispel(IAutomationSurface automation) + { + if (!automation.Spells.TryGet( + EradicateLifeMagicSelf, + out PluginSpellInfo spell) + || !automation.Spells.IsKnown(EradicateLifeMagicSelf) + || !HasVulnerabilityAtOrBelow(automation, spell.Difficulty) + || !automation.Items.CaptureOwnedItems().Any(static item => + item.StackSize > 0 + && item.Name.Equals("Chorizite", StringComparison.Ordinal))) + { + return false; + } + + if (automation.Combat.Snapshot.Mode != PluginCombatMode.Magic) + { + PluginCombatCommandResult mode = automation.Combat.EnterMode( + PluginCombatMode.Magic); + Status = mode.Accepted + ? "Switching to Magic for self dispel" + : "Waiting for Magic mode to self dispel"; + return true; + } + + uint target = automation.Character.ObjectId; + PluginCastGate gate = automation.Magic.EvaluateGate( + EradicateLifeMagicSelf, + target); + if (gate != PluginCastGate.Ready) + { + Status = "Waiting to cast Eradicate Life Magic Self"; + return true; + } + + long revision = automation.Magic.LastCompletion.Revision; + if (!automation.Magic.Cast(EradicateLifeMagicSelf, target)) + { + Status = "Self dispel was refused"; + _retryDelay = 0.25d; + return true; + } + _pending = new Pending( + DispelSource.Spell, + EradicateLifeMagicSelf, + spell.Name, + revision); + _pendingSeconds = 0d; + Status = $"Casting {spell.Name}"; + return true; + } + + private bool TrySelectDispelItem( + IAutomationSurface automation, + out PluginInventoryItem selected) + { + selected = default; + IReadOnlyList inventory = + automation.Items.CaptureOwnedItems(); + if (HasVulnerabilityAtOrBelow(automation, 400) + && TryFind(inventory, HighDifficultyItems, out selected)) + { + return true; + } + return HasVulnerabilityAtOrBelow(automation, 350) + && TryFind(inventory, NormalDifficultyItems, out selected); + } + + private static bool TryFind( + IReadOnlyList inventory, + IEnumerable names, + out PluginInventoryItem selected) + { + foreach (string name in names) + { + foreach (PluginInventoryItem item in inventory) + { + if (item.StackSize > 0 + && item.Name.Equals(name, StringComparison.Ordinal)) + { + selected = item; + return true; + } + } + } + selected = default; + return false; + } + + private bool TryStartAllyDispel(IAutomationSurface automation) + { + if (!automation.Fellowship.IsInFellowship + || !TrySelectAwakener(automation, out PluginInventoryItem drum) + || !TrySelectAlly(automation, out PluginFellowMember target)) + { + return false; + } + + if (automation.Combat.Snapshot.Mode != PluginCombatMode.Magic) + { + PluginCombatCommandResult mode = automation.Combat.EnterMode( + PluginCombatMode.Magic); + Status = mode.Accepted + ? "Switching to Magic for ally dispel" + : "Waiting for Magic mode to dispel ally"; + return true; + } + + long revision = automation.Items.LastCompletion.Revision; + PluginItemCommandResult result = automation.Items.Apply( + drum.ObjectId, + target.ObjectId); + if (!result.Accepted) + { + Status = $"Waiting to use {drum.Name} on {target.Name}"; + return result.Status == PluginItemCommandStatus.Busy; + } + _pending = new Pending( + DispelSource.AllyItem, + drum.ObjectId, + $"{drum.Name} on {target.Name}", + revision); + _pendingSeconds = 0d; + Status = $"Using {drum.Name} on {target.Name}"; + return true; + } + + private static bool TrySelectAwakener( + IAutomationSurface automation, + out PluginInventoryItem selected) + { + selected = default; + if (!automation.Character.TryGetSkill( + CreatureEnchantmentSkill, + out PluginSkillInfo creature) + || !automation.Character.TryGetSkill( + ArcaneLoreSkill, + out PluginSkillInfo arcane) + || arcane.Current < 110u) + { + return false; + } + + foreach (PluginInventoryItem item in automation.Items.CaptureOwnedItems()) + { + if (!item.IsEquipped) + continue; + bool valid = item.Name switch + { + "Awakener" => creature.Training == PluginSkillTraining.Specialized, + "Attenuated Awakener" => creature.Training + is PluginSkillTraining.Trained + or PluginSkillTraining.Specialized, + _ => false, + }; + if (!valid) + continue; + selected = item; + return true; + } + return false; + } + + private static bool TrySelectAlly( + IAutomationSurface automation, + out PluginFellowMember selected) + { + selected = default; + int highestScore = 0; + foreach (PluginFellowMember member in automation.Fellowship.CaptureMembers()) + { + if (member.ObjectId == automation.Character.ObjectId + || member.Distance > AllyDispelRangeMeters) + { + continue; + } + IReadOnlyList tracked = + automation.Enchantments.Capture(member.ObjectId); + if (tracked.Any(static enchantment => + enchantment.SpellId == DispelProtectionSpell + && enchantment.SecondsRemaining > 0d)) + { + continue; + } + + var qualities = new Dictionary(); + foreach (PluginTrackedEnchantment enchantment in tracked) + { + if (enchantment.SecondsRemaining <= 0d + || enchantment.IsUntargeted + || !automation.Spells.TryGet( + enchantment.SpellId, + out PluginSpellInfo spell) + || spell.Difficulty > 350 + || !DebuffSpellCatalog.TryClassify( + spell, + out DebuffIdentity identity, + out _) + || identity.Flag != MonsterActionFlags.Vulnerability + || identity.DamageType == MonsterDamageType.Auto) + { + continue; + } + int quality = enchantment.Quality; + if (!qualities.TryGetValue(identity.DamageType, out int old) + || quality > old) + { + qualities[identity.DamageType] = quality; + } + } + + int score = qualities.Values.Where(static quality => quality > 250).Sum(); + if (score <= highestScore) + continue; + highestScore = score; + selected = member; + } + return selected.ObjectId != 0u; + } + + private static bool HasVulnerabilityAtOrBelow( + IAutomationSurface automation, + int maximumDifficulty) + { + foreach (PluginActiveEnchantment active + in automation.Character.ActiveEnchantments) + { + if (active.SecondsRemaining < 0d + || !automation.Spells.TryGet(active.SpellId, out PluginSpellInfo spell) + || spell.Difficulty > maximumDifficulty + || spell.IsUntargeted + || !DebuffSpellCatalog.TryClassify( + spell, + out DebuffIdentity identity, + out _) + || identity.Flag != MonsterActionFlags.Vulnerability) + { + continue; + } + return true; + } + return false; + } + + private bool ObservePending(double elapsedSeconds) + { + if (_pending is not { } pending) + return false; + _pendingSeconds += elapsedSeconds; + + if (pending.Source == DispelSource.Spell) + { + PluginCastCompletion completion = _host.Automation.Magic.LastCompletion; + if (completion.Revision > pending.Revision) + { + pending.Revision = completion.Revision; + if (completion.SpellId == pending.ObjectId) + return Finish(completion.IsSuccess, completion.WeenieError); + } + } + else + { + PluginItemUseCompletion completion = _host.Automation.Items.LastCompletion; + if (completion.Revision > pending.Revision) + { + pending.Revision = completion.Revision; + if (completion.SourceObjectId == pending.ObjectId) + return Finish(completion.IsSuccess, completion.WeenieError); + } + } + + if (_pendingSeconds < ActionTimeoutSeconds) + return true; + Status = $"Dispel timed out: {pending.Name}"; + ClearPending(); + return true; + } + + private bool Finish(bool succeeded, uint weenieError) + { + string name = _pending?.Name ?? "dispel"; + Status = succeeded + ? $"Dispel completed: {name}" + : $"Dispel failed (0x{weenieError:X}): {name}"; + ClearPending(); + return true; + } + + private void ClearPending() + { + _pending = null; + _pendingSeconds = 0d; + _retryDelay = 0.25d; + } + + private enum DispelSource + { + Spell, + Item, + AllyItem, + } + + private sealed class Pending( + DispelSource source, + uint objectId, + string name, + long revision) + { + public DispelSource Source { get; } = source; + public uint ObjectId { get; } = objectId; + public string Name { get; } = name; + public long Revision { get; set; } = revision; + } +} diff --git a/src/AcDream.Plugins.MossTank/Expressions/CoreExpressionFunctions.cs b/src/AcDream.Plugins.MossTank/Expressions/CoreExpressionFunctions.cs new file mode 100644 index 00000000..17c2c239 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Expressions/CoreExpressionFunctions.cs @@ -0,0 +1,653 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace AcDream.Plugins.MossTank.Expressions; + +/// +/// Presentation- and game-independent UtilityBelt expression functions. +/// World queries and actions are registered by a separate capability adapter; +/// keeping this library pure makes Meta evaluation deterministic in tests and +/// prevents expression code from reaching around the plugin API. +/// +internal static class CoreExpressionFunctions +{ + private static readonly Regex CoordinatePattern = new( + @"^\s*(?[-+]?\d+(?:\.\d+)?)\s*(?[NS])\s*,\s*" + + @"(?[-+]?\d+(?:\.\d+)?)\s*(?[EW])" + + @"(?:\s*,\s*(?[-+]?\d+(?:\.\d+)?))?\s*$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + + public static ExpressionFunctionRegistry CreateDefault(Random? random = null) + { + var registry = new ExpressionFunctionRegistry(); + Register(registry, random); + return registry; + } + + public static void Register( + ExpressionFunctionRegistry registry, + Random? random = null) + { + ArgumentNullException.ThrowIfNull(registry); + RegisterVariables(registry, ExpressionVariableScope.Session, string.Empty); + RegisterVariables(registry, ExpressionVariableScope.Persistent, "p"); + RegisterVariables(registry, ExpressionVariableScope.Global, "g"); + RegisterConversionsAndMath(registry, random ?? Random.Shared); + RegisterLists(registry); + RegisterDictionaries(registry); + RegisterCoordinates(registry); + RegisterTime(registry); + } + + private static void RegisterVariables( + ExpressionFunctionRegistry registry, + ExpressionVariableScope scope, + string infix) + { + string get = "get" + infix + "var"; + string set = "set" + infix + "var"; + string test = "test" + infix + "var"; + string touch = "touch" + infix + "var"; + string clear = "clear" + infix + "var"; + string clearAll = "clearall" + infix + "vars"; + + registry.Register(get, 1, 1, (context, args) => + context.State.Get(scope, args[0].AsString(get)), $"{get}[name]"); + registry.Register(set, 2, 2, (context, args) => + context.State.Set(scope, args[0].AsString(set), args[1]), + $"{set}[name,value]"); + registry.Register(test, 1, 1, (context, args) => + ExpressionValue.Boolean(context.State.Contains( + scope, + args[0].AsString(test))), $"{test}[name]"); + registry.Register(touch, 1, 1, (context, args) => + { + string name = args[0].AsString(touch); + bool existed = context.State.Contains(scope, name); + if (!existed) + context.State.Set(scope, name, ExpressionValue.Zero); + return ExpressionValue.Boolean(existed); + }, $"{touch}[name]"); + registry.Register(clear, 1, 1, (context, args) => + ExpressionValue.Boolean(context.State.Clear( + scope, + args[0].AsString(clear))), $"{clear}[name]"); + registry.Register(clearAll, 0, 0, (context, _) => + { + context.State.Clear(scope); + return ExpressionValue.One; + }, $"{clearAll}[]"); + } + + private static void RegisterConversionsAndMath( + ExpressionFunctionRegistry registry, + Random random) + { + RegisterUnaryMath(registry, "abs", Math.Abs); + RegisterUnaryMath(registry, "acos", Math.Acos); + RegisterUnaryMath(registry, "asin", Math.Asin); + RegisterUnaryMath(registry, "atan", Math.Atan); + RegisterUnaryMath(registry, "ceiling", Math.Ceiling); + RegisterUnaryMath(registry, "cos", Math.Cos); + RegisterUnaryMath(registry, "cosh", Math.Cosh); + RegisterUnaryMath(registry, "floor", Math.Floor); + RegisterUnaryMath(registry, "round", Math.Round); + RegisterUnaryMath(registry, "sin", Math.Sin); + RegisterUnaryMath(registry, "sinh", Math.Sinh); + RegisterUnaryMath(registry, "sqrt", Math.Sqrt); + RegisterUnaryMath(registry, "tan", Math.Tan); + RegisterUnaryMath(registry, "tanh", Math.Tanh); + registry.Register("atan2", 2, 2, (_, args) => ExpressionValue.Number( + Math.Atan2(args[0].AsNumber("atan2"), args[1].AsNumber("atan2"))), + "atan2[y,x]"); + registry.Register("chr", 1, 1, (_, args) => ExpressionValue.String( + char.ConvertFromUtf32(checked((int)args[0].AsNumber("chr")))), + "chr[codepoint]"); + registry.Register("ord", 1, 1, (_, args) => + { + string value = args[0].AsString("ord"); + if (value.Length == 0) + throw new ExpressionEvaluationException("ord expects a non-empty string"); + return ExpressionValue.Number(char.ConvertToUtf32(value, 0)); + }, "ord[text]"); + registry.Register("cnumber", 1, 1, (_, args) => + double.TryParse( + args[0].AsString("cnumber"), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out double result) + ? ExpressionValue.Number(result) + : ExpressionValue.Zero, + "cnumber[text]"); + registry.Register("cstr", 1, 1, (_, args) => ExpressionValue.String( + args[0].AsNumber("cstr").ToString("G15", CultureInfo.InvariantCulture)), + "cstr[number]"); + registry.Register("cstrf", 2, 2, (_, args) => + { + double number = args[0].AsNumber("cstrf"); + string format = args[1].AsString("cstrf"); + return ExpressionValue.String( + format.Contains('X', StringComparison.OrdinalIgnoreCase) + ? checked((uint)number).ToString(format, CultureInfo.InvariantCulture) + : number.ToString(format, CultureInfo.InvariantCulture)); + }, "cstrf[number,format]"); + registry.Register("hexstr", 1, 1, (_, args) => ExpressionValue.String( + $"0x{checked((int)args[0].AsNumber("hexstr")):X}"), + "hexstr[number]"); + registry.Register("strlen", 1, 1, (_, args) => ExpressionValue.Number( + args[0].AsString("strlen").Length), "strlen[text]"); + registry.Register("tostring", 1, 1, (_, args) => + ExpressionValue.String(args[0].ToDisplayString()), "tostring[value]"); + registry.Register("istrue", 1, 1, (_, args) => + ExpressionValue.Boolean(args[0].IsTruthy), "istrue[value]"); + registry.Register("isfalse", 1, 1, (_, args) => + ExpressionValue.Boolean(!args[0].IsTruthy), "isfalse[value]"); + registry.Register("iif", 3, 3, (_, args) => + args[0].IsTruthy ? args[1] : args[2], "iif[test,trueValue,falseValue]"); + registry.Register("ifthen", 2, 3, (context, args) => + { + string? source = args[0].IsTruthy + ? args[1].AsString("ifthen") + : args.Count == 3 + ? args[2].AsString("ifthen") + : null; + return source is null + ? ExpressionValue.Zero + : ExpressionProgram.Compile(source).Evaluate(context); + }, "ifthen[test,trueExpression,falseExpression?]"); + registry.Register("randint", 2, 2, (_, args) => + { + int minimum = checked((int)args[0].AsNumber("randint")); + int maximum = checked((int)args[1].AsNumber("randint")); + return ExpressionValue.Number(random.Next(minimum, maximum)); + }, "randint[min,maxExclusive]"); + registry.Register("getregexmatch", 2, 2, (_, args) => + { + var regex = new Regex( + args[1].AsString("getregexmatch"), + RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + Match match = regex.Match(args[0].AsString("getregexmatch")); + return match.Success + ? ExpressionValue.String(match.Value) + : ExpressionValue.Zero; + }, "getregexmatch[text,pattern]"); + } + + private static void RegisterUnaryMath( + ExpressionFunctionRegistry registry, + string name, + Func operation) => + registry.Register(name, 1, 1, (_, args) => ExpressionValue.Number( + operation(args[0].AsNumber(name))), $"{name}[number]"); + + private static void RegisterLists(ExpressionFunctionRegistry registry) + { + registry.Register("listcreate", 0, int.MaxValue, (_, args) => + ExpressionValue.List(new ExpressionList(args)), "listcreate[items...]"); + registry.Register("listadd", 2, 2, (_, args) => + { + ExpressionList list = args[0].AsList("listadd"); + GuardNoCycle(list, args[1], "listadd"); + list.Items.Add(args[1]); + return args[0]; + }, "listadd[list,item]"); + registry.Register("listinsert", 3, 3, (_, args) => + { + ExpressionList list = args[0].AsList("listinsert"); + GuardNoCycle(list, args[1], "listinsert"); + int index = ToTruncatedInt(args[2], "listinsert"); + if ((uint)index > (uint)list.Items.Count) + throw BadIndex("insert", index, list.Items.Count, allowEnd: true); + list.Items.Insert(index, args[1]); + return args[0]; + }, "listinsert[list,item,index]"); + registry.Register("listremove", 2, 2, (_, args) => + { + args[0].AsList("listremove").Items.Remove(args[1]); + return args[0]; + }, "listremove[list,item]"); + registry.Register("listremoveat", 2, 2, (_, args) => + { + ExpressionList list = args[0].AsList("listremoveat"); + int index = RequireListIndex(list, args[1], "listremoveat"); + list.Items.RemoveAt(index); + return args[0]; + }, "listremoveat[list,index]"); + registry.Register("listgetitem", 2, 2, (_, args) => + { + ExpressionList list = args[0].AsList("listgetitem"); + return list.Items[RequireListIndex(list, args[1], "listgetitem")]; + }, "listgetitem[list,index]"); + registry.Register("listcontains", 2, 2, (_, args) => + ExpressionValue.Boolean(args[0].AsList("listcontains").Items.Contains(args[1])), + "listcontains[list,item]"); + registry.Register("listindexof", 2, 2, (_, args) => ExpressionValue.Number( + args[0].AsList("listindexof").Items.IndexOf(args[1])), + "listindexof[list,item]"); + registry.Register("listlastindexof", 2, 2, (_, args) => + ExpressionValue.Number(args[0].AsList("listlastindexof") + .Items.LastIndexOf(args[1])), "listlastindexof[list,item]"); + registry.Register("listcopy", 1, 1, (_, args) => ExpressionValue.List( + new ExpressionList(args[0].AsList("listcopy").Items)), "listcopy[list]"); + registry.Register("listreverse", 1, 1, (_, args) => + { + var values = args[0].AsList("listreverse").Items.ToArray(); + Array.Reverse(values); + return ExpressionValue.List(new ExpressionList(values)); + }, "listreverse[list]"); + registry.Register("listpop", 1, 2, (_, args) => + { + ExpressionList list = args[0].AsList("listpop"); + int index = args.Count == 1 || args[1].AsNumber("listpop") == -1d + ? list.Items.Count - 1 + : RequireListIndex(list, args[1], "listpop"); + if (index < 0) + throw BadIndex("pop", index, list.Items.Count, allowEnd: false); + ExpressionValue result = list.Items[index]; + list.Items.RemoveAt(index); + return result; + }, "listpop[list,index?]"); + registry.Register("listcount", 1, 1, (_, args) => ExpressionValue.Number( + args[0].AsList("listcount").Items.Count), "listcount[list]"); + registry.Register("listclear", 1, 1, (_, args) => + { + args[0].AsList("listclear").Items.Clear(); + return args[0]; + }, "listclear[list]"); + registry.Register("listfilter", 2, 2, (context, args) => + { + ExpressionList source = args[0].AsList("listfilter"); + ExpressionProgram program = ExpressionProgram.Compile( + args[1].AsString("listfilter")); + var result = new ExpressionList(); + WithIterationVariables(context.State, () => + { + for (int index = 0; index < source.Items.Count; index++) + { + SetIteration(context.State, index, source.Items[index]); + if (program.Evaluate(context).IsTruthy) + result.Items.Add(source.Items[index]); + } + }); + return ExpressionValue.List(result); + }, "listfilter[list,expression]"); + registry.Register("listmap", 2, 2, (context, args) => + { + ExpressionList source = args[0].AsList("listmap"); + ExpressionProgram program = ExpressionProgram.Compile( + args[1].AsString("listmap")); + var result = new ExpressionList(); + WithIterationVariables(context.State, () => + { + for (int index = 0; index < source.Items.Count; index++) + { + SetIteration(context.State, index, source.Items[index]); + result.Items.Add(program.Evaluate(context)); + } + }); + return ExpressionValue.List(result); + }, "listmap[list,expression]"); + registry.Register("listreduce", 2, 2, (context, args) => + { + ExpressionList source = args[0].AsList("listreduce"); + ExpressionProgram program = ExpressionProgram.Compile( + args[1].AsString("listreduce")); + ExpressionValue result = ExpressionValue.Zero; + WithIterationVariables(context.State, () => + { + for (int index = 0; index < source.Items.Count; index++) + { + SetIteration(context.State, index, source.Items[index], result); + result = program.Evaluate(context); + } + }); + return result; + }, "listreduce[list,expression]"); + registry.Register("listsort", 1, 2, (context, args) => + { + var result = new ExpressionList(args[0].AsList("listsort").Items); + if (args.Count == 1 || args[1].AsString("listsort").Length == 0) + { + result.Items.Sort(DefaultValueComparer.Instance); + return ExpressionValue.List(result); + } + + ExpressionProgram program = ExpressionProgram.Compile( + args[1].AsString("listsort")); + WithIterationVariables(context.State, () => + { + // A stable insertion sort avoids the exception wrapping used by + // List.Sort and lets cancellation/budget errors escape intact. + for (int index = 1; index < result.Items.Count; index++) + { + ExpressionValue value = result.Items[index]; + int cursor = index - 1; + while (cursor >= 0) + { + context.State.Set(ExpressionVariableScope.Session, "1", result.Items[cursor]); + context.State.Set(ExpressionVariableScope.Session, "2", value); + if (program.Evaluate(context).AsNumber("listsort comparator") <= 0d) + break; + result.Items[cursor + 1] = result.Items[cursor]; + cursor--; + } + result.Items[cursor + 1] = value; + } + }); + return ExpressionValue.List(result); + }, "listsort[list,expression?]"); + registry.Register("listfromrange", 2, 2, (_, args) => + { + int start = ToTruncatedInt(args[0], "listfromrange"); + int end = ToTruncatedInt(args[1], "listfromrange"); + int count = checked(Math.Abs(end - start) + 1); + if (count > 100_000) + { + throw new ExpressionEvaluationException( + "listfromrange is limited to 100000 entries"); + } + var result = new ExpressionList(); + int step = start <= end ? 1 : -1; + for (int value = start;; value += step) + { + result.Items.Add(ExpressionValue.Number(value)); + if (value == end) + break; + } + return ExpressionValue.List(result); + }, "listfromrange[start,end]"); + } + + private static void RegisterDictionaries(ExpressionFunctionRegistry registry) + { + registry.Register("dictcreate", 0, int.MaxValue, (_, args) => + { + if ((args.Count & 1) != 0) + throw new ExpressionEvaluationException( + "dictcreate expects key/value pairs"); + var dictionary = new ExpressionDictionary(); + for (int index = 0; index < args.Count; index += 2) + { + string key = args[index].AsString("dictcreate key"); + if (!dictionary.Items.TryAdd(key, args[index + 1])) + { + throw new ExpressionEvaluationException( + $"dictcreate received duplicate key '{key}'"); + } + } + return ExpressionValue.Dictionary(dictionary); + }, "dictcreate[key,value,...]"); + registry.Register("dictgetitem", 2, 2, (_, args) => + { + ExpressionDictionary dictionary = args[0].AsDictionary("dictgetitem"); + string key = args[1].AsString("dictgetitem key"); + if (!dictionary.Items.TryGetValue(key, out ExpressionValue value)) + throw new ExpressionEvaluationException($"Dictionary key '{key}' was not found"); + return value; + }, "dictgetitem[dictionary,key]"); + registry.Register("dictadditem", 3, 3, (_, args) => + { + ExpressionDictionary dictionary = args[0].AsDictionary("dictadditem"); + string key = args[1].AsString("dictadditem key"); + GuardNoCycle(dictionary, args[2], "dictadditem"); + bool replaced = dictionary.Items.ContainsKey(key); + dictionary.Items[key] = args[2]; + return ExpressionValue.Boolean(replaced); + }, "dictadditem[dictionary,key,value]"); + registry.Register("dicthaskey", 2, 2, (_, args) => ExpressionValue.Boolean( + args[0].AsDictionary("dicthaskey").Items.ContainsKey( + args[1].AsString("dicthaskey key"))), "dicthaskey[dictionary,key]"); + registry.Register("dictremovekey", 2, 2, (_, args) => ExpressionValue.Boolean( + args[0].AsDictionary("dictremovekey").Items.Remove( + args[1].AsString("dictremovekey key"))), "dictremovekey[dictionary,key]"); + registry.Register("dictkeys", 1, 1, (_, args) => ExpressionValue.List( + new ExpressionList(args[0].AsDictionary("dictkeys").Items.Keys.Select( + ExpressionValue.String))), "dictkeys[dictionary]"); + registry.Register("dictvalues", 1, 1, (_, args) => ExpressionValue.List( + new ExpressionList(args[0].AsDictionary("dictvalues").Items.Values)), + "dictvalues[dictionary]"); + registry.Register("dictsize", 1, 1, (_, args) => ExpressionValue.Number( + args[0].AsDictionary("dictsize").Items.Count), "dictsize[dictionary]"); + registry.Register("dictclear", 1, 1, (_, args) => + { + args[0].AsDictionary("dictclear").Items.Clear(); + return args[0]; + }, "dictclear[dictionary]"); + registry.Register("dictcopy", 1, 1, (_, args) => + { + var result = new ExpressionDictionary(); + foreach ((string key, ExpressionValue value) in + args[0].AsDictionary("dictcopy").Items) + { + result.Items[key] = value; + } + return ExpressionValue.Dictionary(result); + }, "dictcopy[dictionary]"); + } + + private static void RegisterCoordinates(ExpressionFunctionRegistry registry) + { + registry.Register("coordinateparse", 1, 1, (_, args) => + { + string source = args[0].AsString("coordinateparse"); + Match match = CoordinatePattern.Match(source); + if (!match.Success) + { + throw new ExpressionEvaluationException( + $"Unable to parse coordinate '{source}'"); + } + double northSouth = double.Parse( + match.Groups["ns"].Value, + CultureInfo.InvariantCulture); + double eastWest = double.Parse( + match.Groups["ew"].Value, + CultureInfo.InvariantCulture); + if (match.Groups["nsdir"].Value.Equals("S", StringComparison.OrdinalIgnoreCase)) + northSouth = -Math.Abs(northSouth); + else + northSouth = Math.Abs(northSouth); + if (match.Groups["ewdir"].Value.Equals("W", StringComparison.OrdinalIgnoreCase)) + eastWest = -Math.Abs(eastWest); + else + eastWest = Math.Abs(eastWest); + double elevation = match.Groups["z"].Success + ? double.Parse(match.Groups["z"].Value, CultureInfo.InvariantCulture) + : 0d; + return ExpressionValue.Coordinates(new ExpressionCoordinates( + eastWest, + northSouth, + elevation)); + }, "coordinateparse[text]"); + registry.Register("coordinategetns", 1, 1, (_, args) => + ExpressionValue.Number(args[0].AsCoordinates("coordinategetns").NorthSouth), + "coordinategetns[coordinates]"); + registry.Register("coordinategetwe", 1, 1, (_, args) => + ExpressionValue.Number(args[0].AsCoordinates("coordinategetwe").EastWest), + "coordinategetwe[coordinates]"); + registry.Register("coordinategetz", 1, 1, (_, args) => + ExpressionValue.Number(args[0].AsCoordinates("coordinategetz").Elevation), + "coordinategetz[coordinates]"); + registry.Register("coordinatetostring", 1, 1, (_, args) => + ExpressionValue.String(args[0].AsCoordinates("coordinatetostring").ToString()), + "coordinatetostring[coordinates]"); + registry.Register("coordinatedistanceflat", 2, 2, (_, args) => + ExpressionValue.Number(CoordinateDistance(args[0], args[1], includeElevation: false)), + "coordinatedistanceflat[first,second]"); + registry.Register("coordinatedistancewithz", 2, 2, (_, args) => + ExpressionValue.Number(CoordinateDistance(args[0], args[1], includeElevation: true)), + "coordinatedistancewithz[first,second]"); + } + + private static void RegisterTime(ExpressionFunctionRegistry registry) + { + registry.Register("getdatetimelocal", 0, 1, (_, args) => ExpressionValue.String( + DateTime.Now.ToString( + args.Count == 0 ? "hh:mm:ss tt" : args[0].AsString("getdatetimelocal"), + CultureInfo.InvariantCulture)), "getdatetimelocal[format?]"); + registry.Register("getdatetimeutc", 0, 1, (_, args) => ExpressionValue.String( + DateTime.UtcNow.ToString( + args.Count == 0 ? "hh:mm:ss tt" : args[0].AsString("getdatetimeutc"), + CultureInfo.InvariantCulture)), "getdatetimeutc[format?]"); + registry.Register("getunixtime", 0, 0, (_, _) => ExpressionValue.Number( + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000d), "getunixtime[]"); + registry.Register("stopwatchcreate", 0, 0, (_, _) => + ExpressionValue.Stopwatch(new ExpressionStopwatch()), "stopwatchcreate[]"); + registry.Register("stopwatchstart", 1, 1, (_, args) => + { + args[0].AsStopwatch("stopwatchstart").Start(); + return args[0]; + }, "stopwatchstart[stopwatch]"); + registry.Register("stopwatchstop", 1, 1, (_, args) => + { + args[0].AsStopwatch("stopwatchstop").Stop(); + return args[0]; + }, "stopwatchstop[stopwatch]"); + registry.Register("stopwatchelapsedseconds", 1, 1, (_, args) => + ExpressionValue.Number(args[0].AsStopwatch( + "stopwatchelapsedseconds").ElapsedSeconds), + "stopwatchelapsedseconds[stopwatch]"); + } + + private static double CoordinateDistance( + in ExpressionValue first, + in ExpressionValue second, + bool includeElevation) + { + ExpressionCoordinates left = first.AsCoordinates("coordinate distance"); + ExpressionCoordinates right = second.AsCoordinates("coordinate distance"); + double eastWest = (left.EastWest - right.EastWest) * 240d; + double northSouth = (left.NorthSouth - right.NorthSouth) * 240d; + double elevation = includeElevation + ? (left.Elevation - right.Elevation) * 240d + : 0d; + return Math.Sqrt( + eastWest * eastWest + + northSouth * northSouth + + elevation * elevation); + } + + private static int RequireListIndex( + ExpressionList list, + in ExpressionValue value, + string operation) + { + int index = ToTruncatedInt(value, operation); + if ((uint)index >= (uint)list.Items.Count) + throw BadIndex(operation, index, list.Items.Count, allowEnd: false); + return index; + } + + private static int ToTruncatedInt(in ExpressionValue value, string operation) => + checked((int)value.AsNumber(operation)); + + private static ExpressionEvaluationException BadIndex( + string operation, + int index, + int count, + bool allowEnd) => new( + $"Unable to {operation} index {index}; valid range is 0.." + + (allowEnd ? count : count - 1)); + + private static void SetIteration( + ExpressionState state, + int index, + in ExpressionValue item, + ExpressionValue? accumulator = null) + { + state.Set(ExpressionVariableScope.Session, "0", ExpressionValue.Number(index)); + state.Set(ExpressionVariableScope.Session, "1", item); + if (accumulator is { } value) + state.Set(ExpressionVariableScope.Session, "2", value); + } + + private static void WithIterationVariables(ExpressionState state, Action action) + { + var saved = new (string Name, bool Exists, ExpressionValue Value)[3]; + for (int index = 0; index < saved.Length; index++) + { + string name = index.ToString(CultureInfo.InvariantCulture); + saved[index] = ( + name, + state.Contains(ExpressionVariableScope.Session, name), + state.Get(ExpressionVariableScope.Session, name)); + } + try + { + action(); + } + finally + { + foreach ((string name, bool exists, ExpressionValue value) in saved) + { + if (exists) + state.Set(ExpressionVariableScope.Session, name, value); + else + state.Clear(ExpressionVariableScope.Session, name); + } + } + } + + private static void GuardNoCycle(object destination, in ExpressionValue value, string operation) + { + if (ContainsReference(value, destination, new HashSet( + ReferenceEqualityComparer.Instance))) + { + throw new ExpressionEvaluationException( + $"{operation} cannot create a cyclic collection"); + } + } + + private static bool ContainsReference( + in ExpressionValue value, + object destination, + HashSet visited) + { + if (value.Kind == ExpressionValueKind.List) + { + ExpressionList list = value.AsList(); + if (ReferenceEquals(list, destination)) + return true; + return visited.Add(list) + && list.Items.Any(item => ContainsReference(item, destination, visited)); + } + if (value.Kind == ExpressionValueKind.Dictionary) + { + ExpressionDictionary dictionary = value.AsDictionary(); + if (ReferenceEquals(dictionary, destination)) + return true; + return visited.Add(dictionary) + && dictionary.Items.Values.Any(item => + ContainsReference(item, destination, visited)); + } + return false; + } + + private sealed class DefaultValueComparer : IComparer + { + public static DefaultValueComparer Instance { get; } = new(); + + public int Compare(ExpressionValue left, ExpressionValue right) + { + if (left.Kind is ExpressionValueKind.Number or ExpressionValueKind.Boolean + && right.Kind is ExpressionValueKind.Number or ExpressionValueKind.Boolean) + { + return left.AsNumber().CompareTo(right.AsNumber()); + } + if (left.Kind == ExpressionValueKind.String + && right.Kind == ExpressionValueKind.String) + { + return StringComparer.OrdinalIgnoreCase.Compare( + left.AsString(), + right.AsString()); + } + int kind = left.Kind.CompareTo(right.Kind); + return kind != 0 + ? kind + : StringComparer.Ordinal.Compare( + left.ToDisplayString(), + right.ToDisplayString()); + } + } +} diff --git a/src/AcDream.Plugins.MossTank/Expressions/ExperienceMeter.cs b/src/AcDream.Plugins.MossTank/Expressions/ExperienceMeter.cs new file mode 100644 index 00000000..e6537cb2 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Expressions/ExperienceMeter.cs @@ -0,0 +1,84 @@ +using System.Globalization; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank.Expressions; + +/// UtilityBelt-compatible session XP/luminance accumulator. +internal sealed class ExperienceMeter(IPluginHost host) +{ + private long _lastExperience; + private long _lastLuminance; + private bool _hasBaseline; + + public double DurationSeconds { get; private set; } + public long Experience { get; private set; } + public long Luminance { get; private set; } + public double ExperiencePerHour => DurationSeconds > 0d + ? Experience / DurationSeconds * 3600d + : 0d; + public double LuminancePerHour => DurationSeconds > 0d + ? Luminance / DurationSeconds * 3600d + : 0d; + + public void OnTick(double elapsedSeconds) + { + if (!host.Automation.Character.IsInWorld + || !host.Automation.Objects.TryCaptureProperties( + host.Automation.Character.ObjectId, + out PluginItemProperties properties)) + { + _hasBaseline = false; + return; + } + long experience = properties.Int64s.TryGetValue(1u, out long xp) ? xp : 0L; + long luminance = properties.Int64s.TryGetValue(6u, out long lum) ? lum : 0L; + if (!_hasBaseline) + { + _lastExperience = experience; + _lastLuminance = luminance; + _hasBaseline = true; + } + else + { + if (experience >= _lastExperience) + Experience = checked(Experience + experience - _lastExperience); + if (luminance >= _lastLuminance) + Luminance = checked(Luminance + luminance - _lastLuminance); + _lastExperience = experience; + _lastLuminance = luminance; + } + DurationSeconds += elapsedSeconds; + } + + public void Reset() + { + DurationSeconds = 0d; + Experience = 0L; + Luminance = 0L; + _hasBaseline = false; + } + + public string Format() + { + string result = Experience.ToString("N0", CultureInfo.InvariantCulture) + + " XP"; + if (Luminance != 0) + { + result += " and " + + Luminance.ToString("N0", CultureInfo.InvariantCulture) + + " LUM"; + } + result += ", " + + DurationSeconds.ToString("N0", CultureInfo.InvariantCulture) + + "s, " + + ExperiencePerHour.ToString("N0", CultureInfo.InvariantCulture) + + " XP/hr"; + if (Luminance != 0) + { + result += " and " + + LuminancePerHour.ToString("N0", CultureInfo.InvariantCulture) + + " LUM/hr"; + } + return result; + } +} diff --git a/src/AcDream.Plugins.MossTank/Expressions/ExpressionEngine.cs b/src/AcDream.Plugins.MossTank/Expressions/ExpressionEngine.cs new file mode 100644 index 00000000..11e001ec --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Expressions/ExpressionEngine.cs @@ -0,0 +1,789 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace AcDream.Plugins.MossTank.Expressions; + +internal sealed class ExpressionProgram +{ + private readonly Node[] _statements; + + private ExpressionProgram(Node[] statements) => _statements = statements; + + public static ExpressionProgram Compile(string source) + { + if (string.IsNullOrWhiteSpace(source)) + throw new ExpressionParseException("Expression is empty", 0); + return new ExpressionProgram(new Parser(source).ParseProgram()); + } + + public ExpressionValue Evaluate(ExpressionEvaluationContext context) + { + ArgumentNullException.ThrowIfNull(context); + ExpressionValue result = ExpressionValue.Zero; + foreach (Node statement in _statements) + result = statement.Evaluate(context); + return result; + } + + private abstract class Node(int offset) + { + protected int Offset { get; } = offset; + internal abstract ExpressionValue Evaluate(ExpressionEvaluationContext context); + } + + private sealed class LiteralNode(ExpressionValue value, int offset) : Node(offset) + { + internal override ExpressionValue Evaluate(ExpressionEvaluationContext context) + { + context.Step(Offset); + return value; + } + } + + private sealed class VariableNode( + ExpressionVariableScope scope, + Node name, + int offset) : Node(offset) + { + public ExpressionVariableScope Scope { get; } = scope; + + internal override ExpressionValue Evaluate(ExpressionEvaluationContext context) + { + context.Step(Offset); + return context.State.Get(Scope, ResolveName(context)); + } + + public ExpressionValue Set( + ExpressionEvaluationContext context, + ExpressionValue value) => + context.State.Set(Scope, ResolveName(context), value); + + private string ResolveName(ExpressionEvaluationContext context) => + name.Evaluate(context).ToDisplayString(); + } + + private sealed class AssignmentNode( + VariableNode variable, + Node value, + int offset) : Node(offset) + { + internal override ExpressionValue Evaluate(ExpressionEvaluationContext context) + { + context.Step(Offset); + ExpressionValue result = value.Evaluate(context); + return variable.Set(context, result); + } + } + + private sealed class FunctionNode( + string name, + Node[] arguments, + int offset) : Node(offset) + { + internal override ExpressionValue Evaluate(ExpressionEvaluationContext context) + { + var values = new ExpressionValue[arguments.Length]; + for (int index = 0; index < arguments.Length; index++) + values[index] = arguments[index].Evaluate(context); + return context.Invoke(name, values, Offset); + } + } + + private sealed class UnaryNode(TokenKind operation, Node operand, int offset) + : Node(offset) + { + internal override ExpressionValue Evaluate(ExpressionEvaluationContext context) + { + context.Step(Offset); + ExpressionValue value = operand.Evaluate(context); + return operation switch + { + TokenKind.Minus => ExpressionValue.Number( + -value.AsNumber("unary '-'")), + TokenKind.Tilde => ExpressionValue.Number( + ~value.AsInt32("bitwise complement")), + _ => throw new ExpressionEvaluationException( + $"Unsupported unary operator {operation}", Offset), + }; + } + } + + private sealed class BinaryNode( + TokenKind operation, + Node left, + Node right, + int offset) : Node(offset) + { + internal override ExpressionValue Evaluate(ExpressionEvaluationContext context) + { + context.Step(Offset); + ExpressionValue lhs = left.Evaluate(context); + if (operation == TokenKind.AndAnd) + return lhs.IsTruthy ? right.Evaluate(context) : ExpressionValue.Zero; + if (operation == TokenKind.OrOr) + return lhs.IsTruthy ? lhs : right.Evaluate(context); + + ExpressionValue rhs = right.Evaluate(context); + return operation switch + { + TokenKind.Plus => Add(lhs, rhs), + TokenKind.Minus => Subtract(lhs, rhs), + TokenKind.Star => ExpressionValue.Number( + lhs.AsNumber("multiplication") * rhs.AsNumber("multiplication")), + TokenKind.Slash => ExpressionValue.Number( + lhs.AsNumber("division") / rhs.AsNumber("division")), + TokenKind.Percent => ExpressionValue.Number( + lhs.AsNumber("modulo") % rhs.AsNumber("modulo")), + TokenKind.Caret => ExpressionValue.Number(Math.Pow( + lhs.AsNumber("power"), rhs.AsNumber("power"))), + TokenKind.ShiftLeft => ExpressionValue.Number( + lhs.AsInt32("left shift") << rhs.AsInt32("left shift")), + TokenKind.ShiftRight => ExpressionValue.Number( + lhs.AsInt32("right shift") >> rhs.AsInt32("right shift")), + TokenKind.Ampersand => ExpressionValue.Number( + lhs.AsInt32("bitwise and") & rhs.AsInt32("bitwise and")), + TokenKind.Pipe => ExpressionValue.Number( + lhs.AsInt32("bitwise or") | rhs.AsInt32("bitwise or")), + TokenKind.Hash => RegexMatch(context, lhs, rhs), + TokenKind.EqualEqual => ExpressionValue.Boolean(lhs.Equals(rhs)), + TokenKind.BangEqual => ExpressionValue.Boolean(!lhs.Equals(rhs)), + TokenKind.Less => Compare(lhs, rhs, static comparison => comparison < 0), + TokenKind.LessEqual => Compare(lhs, rhs, static comparison => comparison <= 0), + TokenKind.Greater => Compare(lhs, rhs, static comparison => comparison > 0), + TokenKind.GreaterEqual => Compare(lhs, rhs, static comparison => comparison >= 0), + _ => throw new ExpressionEvaluationException( + $"Unsupported binary operator {operation}", Offset), + }; + } + + private static ExpressionValue Add( + in ExpressionValue left, + in ExpressionValue right) + { + if (left.Kind == ExpressionValueKind.Number + || left.Kind == ExpressionValueKind.Boolean) + { + return ExpressionValue.Number( + left.AsNumber("addition") + right.AsNumber("addition")); + } + if (left.Kind == ExpressionValueKind.String) + { + return ExpressionValue.String( + left.AsString("concatenation") + right.ToDisplayString()); + } + throw new ExpressionEvaluationException( + $"Unable to add {left.Kind} to {right.Kind}."); + } + + private static ExpressionValue Subtract( + in ExpressionValue left, + in ExpressionValue right) + { + if (left.Kind is ExpressionValueKind.Number + or ExpressionValueKind.Boolean + && right.Kind is ExpressionValueKind.Number + or ExpressionValueKind.Boolean) + { + return ExpressionValue.Number( + left.AsNumber("subtraction") - right.AsNumber("subtraction")); + } + if (left.Kind == ExpressionValueKind.String + && right.Kind == ExpressionValueKind.String) + { + return ExpressionValue.String( + left.AsString() + "-" + right.AsString()); + } + throw new ExpressionEvaluationException( + $"Unable to subtract {right.Kind} from {left.Kind}."); + } + + private static ExpressionValue Compare( + in ExpressionValue left, + in ExpressionValue right, + Func predicate) + { + double lhs = left.AsNumber("comparison"); + double rhs = right.AsNumber("comparison"); + return ExpressionValue.Boolean(predicate(lhs.CompareTo(rhs))); + } + + private static ExpressionValue RegexMatch( + ExpressionEvaluationContext context, + in ExpressionValue left, + in ExpressionValue right) + { + var regex = new Regex( + right.ToDisplayString(), + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + Match match = regex.Match(left.ToDisplayString()); + foreach (string groupName in regex.GetGroupNames()) + { + string variableName = "capturegroup_" + groupName; + Group group = match.Groups[groupName]; + if (group.Success) + { + context.State.Set( + ExpressionVariableScope.Session, + variableName, + ExpressionValue.String(group.Value)); + } + else + { + context.State.Clear( + ExpressionVariableScope.Session, + variableName); + } + } + return ExpressionValue.Boolean(match.Success); + } + } + + private sealed class IndexNode( + Node source, + Node? start, + Node? end, + bool isSlice, + int offset) : Node(offset) + { + internal override ExpressionValue Evaluate(ExpressionEvaluationContext context) + { + context.Step(Offset); + ExpressionValue value = source.Evaluate(context); + if (value.Kind == ExpressionValueKind.Dictionary) + { + if (isSlice) + { + throw new ExpressionEvaluationException( + "Range indices are not supported with dictionaries", + Offset); + } + string key = (start?.Evaluate(context) ?? ExpressionValue.Zero) + .AsString("dictionary index"); + return value.AsDictionary().Items.TryGetValue( + key, + out ExpressionValue found) + ? found + : ExpressionValue.Zero; + } + + int length = value.Kind switch + { + ExpressionValueKind.List => value.AsList().Items.Count, + ExpressionValueKind.String => value.AsString().Length, + _ => throw new ExpressionEvaluationException( + $"{value.Kind} does not support index access", + Offset), + }; + int first = ResolveIndex(context, start, length, 0, allowEnd: isSlice); + if (!isSlice) + { + return value.Kind == ExpressionValueKind.List + ? value.AsList().Items[first] + : ExpressionValue.String(value.AsString().Substring(first, 1)); + } + int last = ResolveIndex(context, end, length, length, allowEnd: true); + int count = Math.Max(0, last - first); + return value.Kind == ExpressionValueKind.List + ? ExpressionValue.List(new ExpressionList( + value.AsList().Items.Skip(first).Take(count))) + : ExpressionValue.String(value.AsString().Substring(first, count)); + } + + private int ResolveIndex( + ExpressionEvaluationContext context, + Node? expression, + int length, + int defaultValue, + bool allowEnd) + { + int index = expression is null + ? defaultValue + : expression.Evaluate(context).AsInt32("index"); + if (index < 0) + index += length; + int maximum = allowEnd ? length : length - 1; + if (index < 0 || index > maximum) + { + throw new ExpressionEvaluationException( + $"Index {index} is outside 0..{maximum}", + Offset); + } + return index; + } + } + + private enum TokenKind + { + End, + Number, + HexNumber, + String, + True, + False, + LeftParen, + RightParen, + LeftBracket, + RightBracket, + LeftBrace, + RightBrace, + Comma, + Semicolon, + Colon, + Dollar, + At, + Ampersand, + Pipe, + Tilde, + Plus, + Minus, + Star, + Slash, + Percent, + Caret, + Hash, + Equal, + EqualEqual, + BangEqual, + Less, + LessEqual, + Greater, + GreaterEqual, + ShiftLeft, + ShiftRight, + AndAnd, + OrOr, + } + + private readonly record struct Token(TokenKind Kind, string Text, int Offset); + + private sealed class Lexer(string source) + { + private int _offset; + + public Token Next() + { + while (_offset < source.Length && char.IsWhiteSpace(source[_offset])) + _offset++; + if (_offset >= source.Length) + return new Token(TokenKind.End, string.Empty, _offset); + + int start = _offset; + char current = source[_offset]; + if (current is '`' or '\'' or '"') + return ReadQuoted(current, start); + if (char.IsDigit(current) + || (current == '.' + && _offset + 1 < source.Length + && char.IsDigit(source[_offset + 1]))) + { + return ReadNumber(start); + } + if (TryOperator(out Token operation)) + return operation; + + while (_offset < source.Length && !IsDelimiter(source[_offset])) + _offset++; + string text = source[start.._offset].Trim(); + if (text.Length == 0) + { + throw new ExpressionParseException( + $"Unexpected character '{source[start]}'", + start); + } + return text.Equals("true", StringComparison.OrdinalIgnoreCase) + ? new Token(TokenKind.True, text, start) + : text.Equals("false", StringComparison.OrdinalIgnoreCase) + ? new Token(TokenKind.False, text, start) + : new Token(TokenKind.String, Unescape(text), start); + } + + private Token ReadQuoted(char delimiter, int start) + { + _offset++; + var built = new StringBuilder(); + while (_offset < source.Length) + { + char value = source[_offset++]; + if (value == delimiter) + return new Token(TokenKind.String, built.ToString(), start); + if (value == '\\' && _offset < source.Length) + value = source[_offset++]; + built.Append(value); + } + throw new ExpressionParseException("Unterminated string", start); + } + + private Token ReadNumber(int start) + { + if (_offset + 1 < source.Length + && source[_offset] == '0' + && source[_offset + 1] is 'x' or 'X') + { + _offset += 2; + int digits = _offset; + while (_offset < source.Length && Uri.IsHexDigit(source[_offset])) + _offset++; + if (_offset == digits) + throw new ExpressionParseException("Hexadecimal digits expected", start); + return new Token(TokenKind.HexNumber, source[digits.._offset], start); + } + + bool dot = false; + while (_offset < source.Length) + { + char value = source[_offset]; + if (char.IsDigit(value)) + { + _offset++; + continue; + } + if (value == '.' && !dot) + { + dot = true; + _offset++; + continue; + } + break; + } + return new Token(TokenKind.Number, source[start.._offset], start); + } + + private bool TryOperator(out Token token) + { + int start = _offset; + if (_offset + 1 < source.Length) + { + string pair = source.Substring(_offset, 2); + TokenKind pairKind = pair switch + { + "==" => TokenKind.EqualEqual, + "!=" => TokenKind.BangEqual, + "<=" => TokenKind.LessEqual, + ">=" => TokenKind.GreaterEqual, + "<<" => TokenKind.ShiftLeft, + ">>" => TokenKind.ShiftRight, + "&&" => TokenKind.AndAnd, + "||" => TokenKind.OrOr, + _ => TokenKind.End, + }; + if (pairKind != TokenKind.End) + { + _offset += 2; + token = new Token(pairKind, pair, start); + return true; + } + } + + TokenKind kind = source[_offset] switch + { + '(' => TokenKind.LeftParen, + ')' => TokenKind.RightParen, + '[' => TokenKind.LeftBracket, + ']' => TokenKind.RightBracket, + '{' => TokenKind.LeftBrace, + '}' => TokenKind.RightBrace, + ',' => TokenKind.Comma, + ';' => TokenKind.Semicolon, + ':' => TokenKind.Colon, + '$' => TokenKind.Dollar, + '@' => TokenKind.At, + '&' => TokenKind.Ampersand, + '|' => TokenKind.Pipe, + '~' => TokenKind.Tilde, + '+' => TokenKind.Plus, + '-' => TokenKind.Minus, + '*' => TokenKind.Star, + '/' => TokenKind.Slash, + '%' => TokenKind.Percent, + '^' => TokenKind.Caret, + '#' => TokenKind.Hash, + '=' => TokenKind.Equal, + '<' => TokenKind.Less, + '>' => TokenKind.Greater, + _ => TokenKind.End, + }; + if (kind == TokenKind.End) + { + token = default; + return false; + } + _offset++; + token = new Token(kind, source[start].ToString(), start); + return true; + } + + private static bool IsDelimiter(char value) => + char.IsWhiteSpace(value) + ? false + : value is '(' or ')' or '[' or ']' or '{' or '}' + or ',' or ';' or ':' or '$' or '@' or '&' or '|' + or '~' or '+' or '-' or '*' or '/' or '%' or '^' + or '#' or '=' or '!' or '<' or '>' or '`' or '\'' or '"'; + + private static string Unescape(string value) + { + if (!value.Contains('\\', StringComparison.Ordinal)) + return value; + var built = new StringBuilder(value.Length); + for (int index = 0; index < value.Length; index++) + { + char current = value[index]; + if (current == '\\' && index + 1 < value.Length) + current = value[++index]; + built.Append(current); + } + return built.ToString(); + } + } + + private sealed class Parser + { + private readonly Lexer _lexer; + private Token _current; + + public Parser(string source) + { + _lexer = new Lexer(source); + _current = _lexer.Next(); + } + + public Node[] ParseProgram() + { + var statements = new List(); + while (_current.Kind != TokenKind.End) + { + statements.Add(ParseAssignment()); + if (_current.Kind == TokenKind.Semicolon) + { + Advance(); + continue; + } + if (_current.Kind != TokenKind.End) + { + throw new ExpressionParseException( + $"Unexpected token '{_current.Text}'", + _current.Offset); + } + } + return statements.ToArray(); + } + + private Node ParseAssignment() + { + Node left = ParseOr(); + if (_current.Kind != TokenKind.Equal) + return left; + Token operation = _current; + Advance(); + if (left is not VariableNode variable) + { + throw new ExpressionParseException( + "Only variables may appear on the left of '='", + operation.Offset); + } + return new AssignmentNode(variable, ParseAssignment(), operation.Offset); + } + + private Node ParseOr() => ParseLeft(ParseAnd, TokenKind.OrOr); + private Node ParseAnd() => ParseLeft(ParseComparison, TokenKind.AndAnd); + private Node ParseComparison() => ParseLeft( + ParseRegex, + TokenKind.EqualEqual, + TokenKind.BangEqual, + TokenKind.Less, + TokenKind.LessEqual, + TokenKind.Greater, + TokenKind.GreaterEqual); + private Node ParseRegex() => ParseLeft(ParseBitwiseOr, TokenKind.Hash); + private Node ParseBitwiseOr() => ParseLeft(ParseBitwiseAnd, TokenKind.Pipe); + private Node ParseBitwiseAnd() => ParseLeft(ParseShift, TokenKind.Ampersand); + private Node ParseShift() => ParseLeft( + ParseAdditive, + TokenKind.ShiftLeft, + TokenKind.ShiftRight); + private Node ParseAdditive() => ParseLeft( + ParseMultiplicative, + TokenKind.Plus, + TokenKind.Minus); + private Node ParseMultiplicative() => ParseLeft( + ParsePower, + TokenKind.Star, + TokenKind.Slash, + TokenKind.Percent); + + private Node ParsePower() + { + Node left = ParseUnary(); + if (_current.Kind != TokenKind.Caret) + return left; + Token operation = _current; + Advance(); + return new BinaryNode( + operation.Kind, + left, + ParsePower(), + operation.Offset); + } + + private Node ParseUnary() + { + if (_current.Kind is not (TokenKind.Minus or TokenKind.Tilde)) + return ParsePostfix(); + Token operation = _current; + Advance(); + return new UnaryNode(operation.Kind, ParseUnary(), operation.Offset); + } + + private Node ParsePostfix() + { + Node source = ParsePrimary(); + while (_current.Kind == TokenKind.LeftBrace) + { + Token opening = _current; + Advance(); + Node? start = null; + Node? end = null; + bool slice = false; + if (_current.Kind != TokenKind.Colon + && _current.Kind != TokenKind.RightBrace) + { + start = ParseAssignment(); + } + if (_current.Kind == TokenKind.Colon) + { + slice = true; + Advance(); + if (_current.Kind != TokenKind.RightBrace) + end = ParseAssignment(); + } + Require(TokenKind.RightBrace, "Closing '}' expected"); + source = new IndexNode(source, start, end, slice, opening.Offset); + } + return source; + } + + private Node ParsePrimary() + { + Token token = _current; + switch (token.Kind) + { + case TokenKind.Number: + Advance(); + return new LiteralNode( + ExpressionValue.Number(double.Parse( + token.Text, + NumberStyles.Float, + CultureInfo.InvariantCulture)), + token.Offset); + case TokenKind.HexNumber: + Advance(); + return new LiteralNode( + ExpressionValue.Number(Convert.ToUInt32( + token.Text, + 16)), + token.Offset); + case TokenKind.True: + case TokenKind.False: + Advance(); + return new LiteralNode( + ExpressionValue.Boolean(token.Kind == TokenKind.True), + token.Offset); + case TokenKind.String: + Advance(); + if (_current.Kind != TokenKind.LeftBracket) + { + return new LiteralNode( + ExpressionValue.String(token.Text), + token.Offset); + } + return ParseFunction(token); + case TokenKind.Dollar: + case TokenKind.At: + case TokenKind.Ampersand: + return ParseVariable(); + case TokenKind.LeftParen: + Advance(); + Node nested = ParseAssignment(); + Require(TokenKind.RightParen, "Closing ')' expected"); + return nested; + default: + throw new ExpressionParseException( + $"Expression expected; found '{token.Text}'", + token.Offset); + } + } + + private Node ParseFunction(Token name) + { + Require(TokenKind.LeftBracket, "Opening '[' expected"); + var arguments = new List(); + if (_current.Kind != TokenKind.RightBracket) + { + while (true) + { + arguments.Add(ParseAssignment()); + if (_current.Kind != TokenKind.Comma) + break; + Advance(); + } + } + Require(TokenKind.RightBracket, "Closing ']' expected"); + return new FunctionNode(name.Text, arguments.ToArray(), name.Offset); + } + + private Node ParseVariable() + { + Token prefix = _current; + Advance(); + if (_current.Kind is TokenKind.End + or TokenKind.Comma + or TokenKind.Semicolon + or TokenKind.RightBracket + or TokenKind.RightBrace + or TokenKind.RightParen) + { + throw new ExpressionParseException( + "Variable name expected", + _current.Offset); + } + Node name = ParsePrimary(); + ExpressionVariableScope scope = prefix.Kind switch + { + TokenKind.Dollar => ExpressionVariableScope.Session, + TokenKind.At => ExpressionVariableScope.Persistent, + TokenKind.Ampersand => ExpressionVariableScope.Global, + _ => throw new InvalidOperationException(), + }; + return new VariableNode(scope, name, prefix.Offset); + } + + private Node ParseLeft( + Func operand, + params TokenKind[] operations) + { + Node left = operand(); + while (operations.Contains(_current.Kind)) + { + Token operation = _current; + Advance(); + left = new BinaryNode( + operation.Kind, + left, + operand(), + operation.Offset); + } + return left; + } + + private void Require(TokenKind expected, string message) + { + if (_current.Kind != expected) + throw new ExpressionParseException(message, _current.Offset); + Advance(); + } + + private void Advance() => _current = _lexer.Next(); + } +} diff --git a/src/AcDream.Plugins.MossTank/Expressions/ExpressionRuntime.cs b/src/AcDream.Plugins.MossTank/Expressions/ExpressionRuntime.cs new file mode 100644 index 00000000..31c345b1 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Expressions/ExpressionRuntime.cs @@ -0,0 +1,204 @@ +namespace AcDream.Plugins.MossTank.Expressions; + +internal enum ExpressionVariableScope +{ + Session, + Persistent, + Global, +} + +internal sealed class ExpressionState +{ + private readonly Dictionary _session = + new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _persistent = + new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _global = + new(StringComparer.OrdinalIgnoreCase); + + public ExpressionValue Get(ExpressionVariableScope scope, string name) => + Table(scope).TryGetValue(name, out ExpressionValue value) + ? value + : ExpressionValue.Zero; + + public bool Contains(ExpressionVariableScope scope, string name) => + Table(scope).ContainsKey(name); + + public ExpressionValue Set( + ExpressionVariableScope scope, + string name, + ExpressionValue value) + { + Table(scope)[name] = value; + return value; + } + + public bool Clear(ExpressionVariableScope scope, string name) => + Table(scope).Remove(name); + + public void Clear(ExpressionVariableScope scope) => Table(scope).Clear(); + + public IReadOnlyDictionary Capture( + ExpressionVariableScope scope) => + new Dictionary(Table(scope), + StringComparer.OrdinalIgnoreCase); + + public void Replace( + ExpressionVariableScope scope, + IEnumerable> values) + { + Dictionary target = Table(scope); + target.Clear(); + foreach ((string name, ExpressionValue value) in values) + target[name] = value; + } + + private Dictionary Table( + ExpressionVariableScope scope) => scope switch + { + ExpressionVariableScope.Session => _session, + ExpressionVariableScope.Persistent => _persistent, + ExpressionVariableScope.Global => _global, + _ => throw new ArgumentOutOfRangeException(nameof(scope)), + }; +} + +internal delegate ExpressionValue ExpressionFunctionHandler( + ExpressionEvaluationContext context, + IReadOnlyList arguments); + +internal sealed record ExpressionFunction( + string Name, + int MinimumArguments, + int MaximumArguments, + ExpressionFunctionHandler Handler, + string Signature, + string Description); + +internal sealed class ExpressionFunctionRegistry +{ + private readonly Dictionary _functions = + new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyCollection Functions => + _functions.Values; + + public void Register( + string name, + int minimumArguments, + int maximumArguments, + ExpressionFunctionHandler handler, + string? signature = null, + string description = "") + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(handler); + if (minimumArguments < 0 || maximumArguments < minimumArguments) + throw new ArgumentOutOfRangeException(nameof(minimumArguments)); + var function = new ExpressionFunction( + name, + minimumArguments, + maximumArguments, + handler, + signature ?? name + "[...]", + description); + if (!_functions.TryAdd(name, function)) + { + throw new InvalidOperationException( + $"Expression function '{name}' is already registered."); + } + } + + public void Alias(string alias, string existing) + { + if (!_functions.TryGetValue(existing, out ExpressionFunction? function)) + throw new InvalidOperationException( + $"Expression function '{existing}' is not registered."); + Register( + alias, + function.MinimumArguments, + function.MaximumArguments, + function.Handler, + function.Signature.Replace(existing, alias, StringComparison.Ordinal), + function.Description); + } + + public ExpressionFunction Resolve(string name, int offset) + { + if (_functions.TryGetValue(name, out ExpressionFunction? function)) + return function; + throw new ExpressionEvaluationException( + $"Unknown expression method: {name}", + offset); + } +} + +internal sealed class ExpressionEvaluationContext +{ + private int _remainingInstructions; + + public ExpressionEvaluationContext( + ExpressionState state, + ExpressionFunctionRegistry functions, + int instructionBudget = 10_000, + CancellationToken cancellationToken = default) + { + State = state ?? throw new ArgumentNullException(nameof(state)); + Functions = functions ?? throw new ArgumentNullException(nameof(functions)); + if (instructionBudget <= 0) + throw new ArgumentOutOfRangeException(nameof(instructionBudget)); + _remainingInstructions = instructionBudget; + CancellationToken = cancellationToken; + } + + public ExpressionState State { get; } + public ExpressionFunctionRegistry Functions { get; } + public CancellationToken CancellationToken { get; } + public int RemainingInstructions => _remainingInstructions; + + public void Step(int offset) + { + CancellationToken.ThrowIfCancellationRequested(); + if (--_remainingInstructions < 0) + { + throw new ExpressionEvaluationException( + "Expression instruction budget exceeded", + offset); + } + } + + public ExpressionValue Invoke( + string name, + IReadOnlyList arguments, + int offset) + { + Step(offset); + ExpressionFunction function = Functions.Resolve(name, offset); + if (arguments.Count < function.MinimumArguments + || arguments.Count > function.MaximumArguments) + { + string expected = function.MinimumArguments == function.MaximumArguments + ? function.MinimumArguments.ToString( + System.Globalization.CultureInfo.InvariantCulture) + : $"{function.MinimumArguments}..{function.MaximumArguments}"; + throw new ExpressionEvaluationException( + $"{function.Signature} expects {expected} arguments; " + + $"{arguments.Count} were passed", + offset); + } + try + { + return function.Handler(this, arguments); + } + catch (ExpressionEvaluationException) + { + throw; + } + catch (Exception error) + { + throw new ExpressionEvaluationException( + $"{function.Signature} failed: {error.Message}", + offset); + } + } +} diff --git a/src/AcDream.Plugins.MossTank/Expressions/ExpressionValue.cs b/src/AcDream.Plugins.MossTank/Expressions/ExpressionValue.cs new file mode 100644 index 00000000..9362da61 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Expressions/ExpressionValue.cs @@ -0,0 +1,240 @@ +using System.Globalization; + +namespace AcDream.Plugins.MossTank.Expressions; + +internal enum ExpressionValueKind +{ + Number, + String, + Boolean, + List, + Dictionary, + Coordinates, + WorldObject, + Stopwatch, + UiControl, +} + +internal readonly record struct ExpressionCoordinates( + double EastWest, + double NorthSouth, + double Elevation = 0d) +{ + public override string ToString() + { + string ns = NorthSouth < 0d ? "S" : "N"; + string ew = EastWest < 0d ? "W" : "E"; + return string.Create( + CultureInfo.InvariantCulture, + $"{Math.Abs(NorthSouth):0.0}{ns}, {Math.Abs(EastWest):0.0}{ew}"); + } +} + +internal sealed class ExpressionList +{ + public List Items { get; } = []; + + public ExpressionList() + { + } + + public ExpressionList(IEnumerable values) => + Items.AddRange(values); + + public override string ToString() => + $"[{string.Join(",", Items.Select(static item => item.ToDisplayString()))}]"; +} + +internal sealed class ExpressionDictionary +{ + public Dictionary Items { get; } = + new(StringComparer.Ordinal); + + public override string ToString() => + $"[{string.Join(",", Items.Select(static pair => + pair.Key + "=>" + pair.Value.ToDisplayString()))}]"; +} + +internal sealed class ExpressionStopwatch +{ + private readonly System.Diagnostics.Stopwatch _clock = new(); + + public bool IsRunning => _clock.IsRunning; + public double ElapsedSeconds => _clock.Elapsed.TotalSeconds; + public void Start() => _clock.Start(); + public void Stop() => _clock.Stop(); + public void Reset() => _clock.Reset(); + public void Restart() => _clock.Restart(); + public override string ToString() => + ElapsedSeconds.ToString("0.###", CultureInfo.InvariantCulture); +} + +internal readonly record struct ExpressionWorldObject(uint ObjectId); +internal readonly record struct ExpressionUiControl(string View, string Control); + +internal readonly struct ExpressionValue : IEquatable +{ + private readonly double _number; + private readonly object? _reference; + + private ExpressionValue( + ExpressionValueKind kind, + double number, + object? reference) + { + Kind = kind; + _number = number; + _reference = reference; + } + + public ExpressionValueKind Kind { get; } + + public static ExpressionValue Zero => Number(0d); + public static ExpressionValue One => Number(1d); + public static ExpressionValue Number(double value) => + new(ExpressionValueKind.Number, value, null); + public static ExpressionValue String(string? value) => + new(ExpressionValueKind.String, 0d, value ?? string.Empty); + public static ExpressionValue Boolean(bool value) => + new(ExpressionValueKind.Boolean, value ? 1d : 0d, null); + public static ExpressionValue List(ExpressionList value) => + new(ExpressionValueKind.List, 0d, value); + public static ExpressionValue Dictionary(ExpressionDictionary value) => + new(ExpressionValueKind.Dictionary, 0d, value); + public static ExpressionValue Coordinates(ExpressionCoordinates value) => + new(ExpressionValueKind.Coordinates, 0d, value); + public static ExpressionValue WorldObject(uint objectId) => + new(ExpressionValueKind.WorldObject, objectId, null); + public static ExpressionValue Stopwatch(ExpressionStopwatch value) => + new(ExpressionValueKind.Stopwatch, 0d, value); + public static ExpressionValue UiControl(ExpressionUiControl value) => + new(ExpressionValueKind.UiControl, 0d, value); + + public double AsNumber(string? operation = null) + { + if (Kind is ExpressionValueKind.Number or ExpressionValueKind.Boolean) + return _number; + throw TypeError(operation ?? "operation", "number"); + } + + public int AsInt32(string? operation = null) => + Convert.ToInt32(AsNumber(operation), CultureInfo.InvariantCulture); + + public string AsString(string? operation = null) + { + if (Kind == ExpressionValueKind.String) + return (string)_reference!; + throw TypeError(operation ?? "operation", "string"); + } + + public ExpressionList AsList(string? operation = null) => + Kind == ExpressionValueKind.List + ? (ExpressionList)_reference! + : throw TypeError(operation ?? "operation", "list"); + + public ExpressionDictionary AsDictionary(string? operation = null) => + Kind == ExpressionValueKind.Dictionary + ? (ExpressionDictionary)_reference! + : throw TypeError(operation ?? "operation", "dictionary"); + + public ExpressionCoordinates AsCoordinates(string? operation = null) => + Kind == ExpressionValueKind.Coordinates + ? (ExpressionCoordinates)_reference! + : throw TypeError(operation ?? "operation", "coordinates"); + + public ExpressionStopwatch AsStopwatch(string? operation = null) => + Kind == ExpressionValueKind.Stopwatch + ? (ExpressionStopwatch)_reference! + : throw TypeError(operation ?? "operation", "stopwatch"); + + public ExpressionUiControl AsUiControl(string? operation = null) => + Kind == ExpressionValueKind.UiControl + ? (ExpressionUiControl)_reference! + : throw TypeError(operation ?? "operation", "UI control"); + + public uint AsObjectId(string? operation = null) => Kind switch + { + ExpressionValueKind.WorldObject => checked((uint)_number), + ExpressionValueKind.Number => checked((uint)_number), + _ => throw TypeError(operation ?? "operation", "world object"), + }; + + public bool IsTruthy => Kind switch + { + ExpressionValueKind.Number or ExpressionValueKind.Boolean => + _number != 0d, + ExpressionValueKind.String => ((string)_reference!).Length != 0, + _ => true, + }; + + public string ToDisplayString() => Kind switch + { + ExpressionValueKind.Number => + _number.ToString("G15", CultureInfo.InvariantCulture), + ExpressionValueKind.Boolean => _number != 0d ? "True" : "False", + ExpressionValueKind.String => (string)_reference!, + ExpressionValueKind.List => _reference!.ToString()!, + ExpressionValueKind.Dictionary => _reference!.ToString()!, + ExpressionValueKind.Coordinates => _reference!.ToString()!, + ExpressionValueKind.WorldObject => + checked((uint)_number).ToString(CultureInfo.InvariantCulture), + ExpressionValueKind.Stopwatch => _reference!.ToString()!, + ExpressionValueKind.UiControl => _reference!.ToString()!, + _ => string.Empty, + }; + + public bool Equals(ExpressionValue other) + { + if (Kind == ExpressionValueKind.String) + { + return other.Kind == ExpressionValueKind.String + && string.Equals( + (string)_reference!, + (string)other._reference!, + StringComparison.OrdinalIgnoreCase); + } + if (Kind is ExpressionValueKind.Number or ExpressionValueKind.Boolean + && other.Kind is ExpressionValueKind.Number + or ExpressionValueKind.Boolean) + { + return _number.Equals(other._number); + } + return Kind == other.Kind && ReferenceEquals(_reference, other._reference); + } + + public override bool Equals(object? obj) => + obj is ExpressionValue other && Equals(other); + + public override int GetHashCode() => Kind switch + { + ExpressionValueKind.String => StringComparer.OrdinalIgnoreCase.GetHashCode( + (string)_reference!), + ExpressionValueKind.Number or ExpressionValueKind.Boolean => + _number.GetHashCode(), + _ => HashCode.Combine(Kind, _reference), + }; + + public override string ToString() => ToDisplayString(); + + private ExpressionEvaluationException TypeError( + string operation, + string expected) => new( + $"{operation} expects {expected}, but received {Kind}."); +} + +internal sealed class ExpressionParseException : Exception +{ + public ExpressionParseException(string message, int offset) + : base($"{message} at offset {offset}.") => Offset = offset; + + public int Offset { get; } +} + +internal sealed class ExpressionEvaluationException : Exception +{ + public ExpressionEvaluationException(string message, int offset = -1) + : base(offset < 0 ? message : $"{message} at offset {offset}.") => + Offset = offset; + + public int Offset { get; } +} diff --git a/src/AcDream.Plugins.MossTank/Expressions/HostExpressionFunctions.cs b/src/AcDream.Plugins.MossTank/Expressions/HostExpressionFunctions.cs new file mode 100644 index 00000000..d95841ce --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Expressions/HostExpressionFunctions.cs @@ -0,0 +1,1276 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank.Expressions; + +/// +/// UtilityBelt-compatible expression methods backed by acdream's canonical +/// plugin automation surface. This class contains translation only; macro +/// policy remains in MossTank and authoritative game state remains in Runtime. +/// +internal static class HostExpressionFunctions +{ + private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(100); + private static readonly string[] GameMonthNames = + [ + "Morningthaw", "Solclaim", "Seedsow", "Leafdawning", "Verdantine", + "Thistledown", "HarvestGain", "Leafcull", "Frostfell", "Snowreap", + "Coldeve", "Wintersebb", + ]; + private static readonly string[] GameHourNames = + [ + "Darktide", "Darktide-and-Half", "Foredawn", "Foredawn-and-Half", + "Dawnsong", "Dawnsong-and-Half", "Morntide", "Morntide-and-Half", + "Midsong", "Midsong-and-Half", "Warmtide", "Warmtide-and-Half", + "Evensong", "Evensong-and-Half", "Gloaming", "Gloaming-and-Half", + ]; + + public static void Register(ExpressionFunctionRegistry registry, IPluginHost host) + { + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(host); + RegisterCharacter(registry, host); + RegisterSpells(registry, host); + RegisterObjects(registry, host); + RegisterLoot(registry, host); + RegisterFellowship(registry, host); + RegisterWorldTime(registry, host); + RegisterUi(registry, host); + RegisterActions(registry, host); + RegisterCombatAndMovement(registry, host); + RegisterLogin(registry, host); + RegisterNetwork(registry, host); + } + + private static void RegisterUi( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + registry.Register("uigetcontrol", 2, 2, (_, args) => + { + string view = args[0].AsString("uigetcontrol"); + string control = args[1].AsString("uigetcontrol"); + return host.Ui.ControlExists(view, control) + ? ExpressionValue.UiControl(new ExpressionUiControl(view, control)) + : ExpressionValue.Zero; + }, "uigetcontrol[windowName,controlName]"); + registry.Register("uisetlabel", 2, 2, (_, args) => + { + ExpressionUiControl control = args[0].AsUiControl("uisetlabel"); + return ExpressionValue.Boolean(host.Ui.SetControlLabel( + control.View, + control.Control, + args[1].AsString("uisetlabel"))); + }, "uisetlabel[control,label]"); + registry.Register("uisetvisible", 2, 2, (_, args) => + { + ExpressionUiControl control = args[0].AsUiControl("uisetvisible"); + return ExpressionValue.Boolean(host.Ui.SetControlVisible( + control.View, + control.Control, + args[1].AsNumber("uisetvisible") >= 1d)); + }, "uisetvisible[control,visible]"); + registry.Register("uiviewexists", 1, 1, (_, args) => + ExpressionValue.Boolean(host.Ui.ViewExists( + args[0].AsString("uiviewexists"))), "uiviewexists[windowName]"); + registry.Register("uiviewvisible", 1, 1, (_, args) => + ExpressionValue.Boolean(host.Ui.IsViewVisible( + args[0].AsString("uiviewvisible"))), "uiviewvisible[windowName]"); + } + + private static void RegisterLoot( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + registry.Register("hascorpsebeenopenedbyme", 1, 1, (_, args) => + { + uint objectId = args[0].AsObjectId("hascorpsebeenopenedbyme"); + return ExpressionValue.Boolean(host.Automation.Loot + .CaptureCorpses(float.MaxValue) + .Any(corpse => corpse.ObjectId == objectId && corpse.HasBeenOpened)); + }, "hascorpsebeenopenedbyme[corpse]"); + registry.Register("getcorpsesunopenedbyme", 0, 0, (_, _) => + NumberList(host.Automation.Loot.CaptureCorpses(float.MaxValue) + .Where(static corpse => !corpse.HasBeenOpened) + .Select(static corpse => corpse.ObjectId)), + "getcorpsesunopenedbyme[]"); + } + + private static void RegisterCharacter( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + ICharacterInfo character = host.Automation.Character; + registry.Register("getworldname", 0, 0, (_, _) => + ExpressionValue.String(character.WorldName), "getworldname[]"); + registry.Register("getaccounthash", 0, 0, (_, _) => + ExpressionValue.String(LegacyStringHash(character.AccountName) + .ToString("X", CultureInfo.InvariantCulture)), "getaccounthash[]"); + registry.Register("getcharacterindex", 0, 1, (_, args) => + { + string name = args.Count == 0 + ? character.Name + : args[0].AsString("getcharacterindex"); + IReadOnlyList roster = + host.Automation.Login.CaptureRoster(); + if (roster.Count == 0) + return ExpressionValue.Number(character.CharacterIndex); + for (int index = 0; index < roster.Count; index++) + { + if (roster[index].Name.Contains( + name, + StringComparison.OrdinalIgnoreCase)) + { + return ExpressionValue.Number(index); + } + } + return ExpressionValue.Number(-1d); + }, "getcharacterindex[name?]"); + registry.Register("getplayercoordinates", 0, 0, (_, _) => + { + PluginNavigationSnapshot snapshot = host.Automation.Navigation.Snapshot; + return snapshot.IsAvailable + ? Coordinates(snapshot.Position) + : ExpressionValue.Zero; + }, "getplayercoordinates[]"); + registry.Register("getplayerlandblock", 0, 0, (_, _) => + ExpressionValue.Number( + host.Automation.Navigation.Snapshot.Position.CellId >> 16), + "getplayerlandblock[]"); + registry.Register("getplayerlandcell", 0, 0, (_, _) => + ExpressionValue.Number( + host.Automation.Navigation.Snapshot.Position.CellId), + "getplayerlandcell[]"); + registry.Register("isportaling", 0, 0, (_, _) => + ExpressionValue.Boolean( + !character.IsInWorld + || host.Automation.Navigation.Snapshot.IsPortalSpace), + "isportaling[]"); + registry.Register("getcharburden", 0, 0, (_, _) => + { + // Decal/UB reports the friendly percentage from the same two + // retail properties: EncumbranceVal (5) and EncumbranceCapacity (96). + if (!TryPlayerProperties(host, out PluginItemProperties properties)) + return ExpressionValue.Zero; + double burden = Get(properties.Ints, 5u); + double capacity = Get(properties.Ints, 96u); + return ExpressionValue.Number(capacity > 0d + ? Math.Round(burden * 100d / capacity) + : 0d); + }, "getcharburden[]"); + registry.Register("getcharintprop", 1, 1, (_, args) => + ExpressionValue.Number(PlayerProperty(host, args[0], PropertyKind.Int)), + "getcharintprop[property]"); + registry.Register("getchardoubleprop", 1, 1, (_, args) => + ExpressionValue.Number(PlayerProperty(host, args[0], PropertyKind.Double)), + "getchardoubleprop[property]"); + registry.Register("getcharquadprop", 1, 1, (_, args) => + ExpressionValue.Number(PlayerProperty(host, args[0], PropertyKind.Int64)), + "getcharquadprop[property]"); + registry.Register("getcharboolprop", 1, 1, (_, args) => + ExpressionValue.Boolean(PlayerProperty(host, args[0], PropertyKind.Bool) != 0d), + "getcharboolprop[property]"); + registry.Register("getcharstringprop", 1, 1, (_, args) => + { + if (!TryPlayerProperties(host, out PluginItemProperties properties)) + return ExpressionValue.Zero; + uint key = ToUInt(args[0], "getcharstringprop"); + return properties.Strings.TryGetValue(key, out string? value) + ? ExpressionValue.String(value) + : ExpressionValue.Zero; + }, "getcharstringprop[property]"); + + registry.Register("getcharattribute_base", 1, 1, (_, args) => + ExpressionValue.Number(Attribute(character, args[0], buffed: false)), + "getcharattribute_base[attributeId]"); + registry.Register("getcharattribute_buffed", 1, 1, (_, args) => + ExpressionValue.Number(Attribute(character, args[0], buffed: true)), + "getcharattribute_buffed[attributeId]"); + registry.Register("getcharskill_base", 1, 1, (_, args) => + ExpressionValue.Number(Skill(character, args[0], SkillRead.Base)), + "getcharskill_base[skillId]"); + registry.Register("getcharskill_buffed", 1, 1, (_, args) => + ExpressionValue.Number(Skill(character, args[0], SkillRead.Buffed)), + "getcharskill_buffed[skillId]"); + registry.Register("getcharskill_traininglevel", 1, 1, (_, args) => + ExpressionValue.Number(Skill(character, args[0], SkillRead.Training)), + "getcharskill_traininglevel[skillId]"); + registry.Register("getcharvital_base", 1, 1, (_, args) => + ExpressionValue.Number(Vital(character, args[0], VitalRead.Maximum)), + "getcharvital_base[vitalId]"); + registry.Register("getcharvital_buffedmax", 1, 1, (_, args) => + ExpressionValue.Number(Vital(character, args[0], VitalRead.Maximum)), + "getcharvital_buffedmax[vitalId]"); + registry.Register("getcharvital_current", 1, 1, (_, args) => + ExpressionValue.Number(Vital(character, args[0], VitalRead.Current)), + "getcharvital_current[vitalId]"); + registry.Register("vitae", 0, 0, (_, _) => + { + if (!TryPlayerProperties(host, out PluginItemProperties properties)) + return ExpressionValue.Number(100d); + // PropertyFloat.Vitae (129) is the multiplier 0..1. + double value = Get(properties.Floats, 129u, 1d); + return ExpressionValue.Number(value * 100d); + }, "vitae[]"); + } + + private static void RegisterLogin( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + registry.Register("setnextlogin", 1, 1, (_, args) => + { + ILoginAutomation login = host.Automation.Login; + IReadOnlyList roster = login.CaptureRoster(); + if (!login.IsAvailable || roster.Count == 0) + return ExpressionValue.Zero; + + string selector = args[0].ToDisplayString(); + PluginLoginCharacter selected = default; + if (int.TryParse( + selector, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out int relative)) + { + int current = -1; + for (int index = 0; index < roster.Count; index++) + { + if (roster[index].Name.Equals( + host.Automation.Character.Name, + StringComparison.OrdinalIgnoreCase)) + { + current = index; + break; + } + } + if (current < 0) + return ExpressionValue.Zero; + int target = ((current + relative) % roster.Count + roster.Count) + % roster.Count; + selected = roster[target]; + } + else + { + foreach (PluginLoginCharacter candidate in roster) + { + if (candidate.Name.Contains( + selector, + StringComparison.OrdinalIgnoreCase)) + { + selected = candidate; + break; + } + } + } + return ExpressionValue.Boolean( + selected.ObjectId != 0u + && !selected.IsPendingDelete + && login.SetNextLogin(selected.ObjectId)); + }, "setnextlogin[nameOrRelativeIndex]"); + registry.Register("clearnextlogin", 0, 0, (_, _) => + ExpressionValue.Boolean(host.Automation.Login.ClearNextLogin()), + "clearnextlogin[]"); + } + + private static void RegisterNetwork( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + registry.Register("netclients", 0, 1, (_, args) => + { + string? tag = args.Count == 0 + ? null + : args[0].AsString("netclients"); + var clients = new ExpressionList(); + foreach (PluginNetworkClient client in + host.Automation.Network.CaptureClients()) + { + if (!string.IsNullOrEmpty(tag) + && !client.Tags.Any(candidate => candidate.Equals( + tag, + StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + var tags = new ExpressionList(); + foreach (string candidate in client.Tags) + tags.Items.Add(ExpressionValue.String(candidate)); + var data = new ExpressionDictionary(); + data.Items["ClientId"] = ExpressionValue.Number(client.ClientId); + data.Items["PlayerId"] = ExpressionValue.Number(client.PlayerId); + data.Items["Position"] = Coordinates(client.Position); + data.Items["Name"] = ExpressionValue.String(client.Name); + data.Items["Tags"] = ExpressionValue.List(tags); + data.Items["WorldName"] = ExpressionValue.String(client.WorldName); + data.Items["CurrentHealth"] = + ExpressionValue.Number(client.CurrentHealth); + data.Items["CurrentMana"] = + ExpressionValue.Number(client.CurrentMana); + data.Items["CurrentStamina"] = + ExpressionValue.Number(client.CurrentStamina); + data.Items["MaxHealth"] = ExpressionValue.Number(client.MaxHealth); + data.Items["MaxMana"] = ExpressionValue.Number(client.MaxMana); + data.Items["MaxStamina"] = + ExpressionValue.Number(client.MaxStamina); + data.Items["Heading"] = ExpressionValue.Number(client.Heading); + clients.Items.Add(ExpressionValue.Dictionary(data)); + } + return ExpressionValue.List(clients); + }, "netclients[tag?]"); + } + + private static void RegisterSpells( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + ISpellCatalog spells = host.Automation.Spells; + ICharacterInfo character = host.Automation.Character; + registry.Register("componentname", 1, 1, (_, args) => + spells.TryGetComponent( + ToUInt(args[0], "componentname"), + out PluginSpellComponentInfo component) + ? ExpressionValue.String(component.Name) + : ExpressionValue.Zero, + "componentname[componentid]"); + registry.Register("componentdata", 1, 1, (_, args) => + { + if (!spells.TryGetComponent( + ToUInt(args[0], "componentdata"), + out PluginSpellComponentInfo component)) + { + return ExpressionValue.Dictionary(new ExpressionDictionary()); + } + var data = new ExpressionDictionary(); + data.Items["BurnRate"] = ExpressionValue.Number(component.BurnRate); + data.Items["GestureId"] = ExpressionValue.Number(component.GestureId); + data.Items["GestureSpeed"] = ExpressionValue.Number(component.GestureSpeed); + data.Items["IconId"] = ExpressionValue.Number(component.IconId); + data.Items["Id"] = ExpressionValue.Number(component.ComponentId); + data.Items["Name"] = ExpressionValue.String(component.Name); + data.Items["SortKey"] = ExpressionValue.Number(component.SortKey); + data.Items["Type"] = ExpressionValue.String(component.Type); + data.Items["Word"] = ExpressionValue.String(component.Word); + return ExpressionValue.Dictionary(data); + }, "componentdata[componentid]"); + registry.Register("getisspellknown", 1, 1, (_, args) => + ExpressionValue.Boolean(spells.IsKnown(ToUInt(args[0], "getisspellknown"))), + "getisspellknown[spellId]"); + registry.Register("getknownspells", 0, 0, (_, _) => + { + IEnumerable ids = spells.KnownSelfBuffs + .Concat(spells.KnownCombatSpells) + .Select(static spell => spell.SpellId) + .Distinct() + .Order(); + return NumberList(ids); + }, "getknownspells[]"); + registry.Register("spellname", 1, 1, (_, args) => + spells.TryGet(ToUInt(args[0], "spellname"), out PluginSpellInfo spell) + ? ExpressionValue.String(spell.Name) + : ExpressionValue.Zero, "spellname[spellId]"); + registry.Register("spelldata", 2, 2, (_, args) => + { + if (!spells.TryGet(ToUInt(args[0], "spelldata"), out PluginSpellInfo spell)) + return ExpressionValue.Zero; + return SpellProperty(spell, args[1].ToDisplayString()); + }, "spelldata[spellId,property]"); + registry.Register("getspellexpiration", 1, 1, (_, args) => + ExpressionValue.Number(SpellExpiration( + character.ActiveEnchantments, + ToUInt(args[0], "getspellexpiration"))), + "getspellexpiration[spellId]"); + registry.Register("getspellexpirationbyname", 1, 1, (_, args) => + { + string name = args[0].AsString("getspellexpirationbyname"); + PluginSpellInfo? match = spells.KnownSelfBuffs + .Concat(spells.KnownCombatSpells) + .FirstOrDefault(spell => spell.Name.Equals( + name, + StringComparison.OrdinalIgnoreCase)); + return ExpressionValue.Number(match is { SpellId: > 0u } spell + ? SpellExpiration(character.ActiveEnchantments, spell.SpellId) + : 0d); + }, "getspellexpirationbyname[name]"); + registry.Register("getcooldownexpiration", 1, 1, (_, args) => + ExpressionValue.Number(spells.GetCooldownRemaining( + ToUInt(args[0], "getcooldownexpiration"))), + "getcooldownexpiration[cooldownId]"); + registry.Register("getcancastspell_buff", 1, 1, (_, args) => + ExpressionValue.Boolean(host.Automation.Magic.EvaluateGate( + ToUInt(args[0], "getcancastspell_buff")) == PluginCastGate.Ready), + "getcancastspell_buff[spellId]"); + registry.Register("getcancastspell_hunt", 1, 1, (_, args) => + ExpressionValue.Boolean(host.Automation.Magic.EvaluateGate( + ToUInt(args[0], "getcancastspell_hunt")) == PluginCastGate.Ready), + "getcancastspell_hunt[spellId]"); + } + + private static void RegisterObjects( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + IWorldObjectAutomation objects = host.Automation.Objects; + registry.Register("wobjectfindbyid", 1, 1, (context, args) => + { + uint id = ToUInt(args[0], "wobjectfindbyid"); + return objects.TryGet(id, out _) + ? ExpressionValue.WorldObject(id) + : ExpressionValue.Zero; + }, "wobjectfindbyid[id]"); + registry.Register("wobjectgetplayer", 0, 0, (_, _) => + ExpressionValue.WorldObject(host.Automation.Character.ObjectId), + "wobjectgetplayer[]"); + registry.Register("wobjectgetselection", 0, 0, (_, _) => + host.Selection.SelectedObjectId is uint id + ? ExpressionValue.WorldObject(id) + : ExpressionValue.Zero, "wobjectgetselection[]"); + registry.Register("wobjectgetopencontainer", 0, 0, (_, _) => + objects.OpenContainerObjectId != 0u + ? ExpressionValue.WorldObject(objects.OpenContainerObjectId) + : ExpressionValue.Zero, "wobjectgetopencontainer[]"); + registry.Register("wobjectgetid", 1, 1, (_, args) => + ExpressionValue.Number(args[0].AsObjectId("wobjectgetid")), + "wobjectgetid[object]"); + registry.Register("wobjectgetname", 1, 1, (_, args) => + TryObject(objects, args[0], "wobjectgetname", out PluginWorldObject obj) + ? ExpressionValue.String(obj.Name) + : ExpressionValue.Zero, "wobjectgetname[object]"); + registry.Register("wobjectgetobjectclass", 1, 1, (_, args) => + TryObject(objects, args[0], "wobjectgetobjectclass", out PluginWorldObject obj) + ? ExpressionValue.Number((int)obj.ObjectClass) + : ExpressionValue.Zero, "wobjectgetobjectclass[object]"); + registry.Register("wobjectgettemplatetype", 1, 1, (_, args) => + TryObject(objects, args[0], "wobjectgettemplatetype", out PluginWorldObject obj) + ? ExpressionValue.Number(obj.WeenieClassId) + : ExpressionValue.Zero, "wobjectgettemplatetype[object]"); + registry.Register("wobjectgetinternaltype", 1, 1, (_, args) => + TryObject(objects, args[0], "wobjectgetinternaltype", out PluginWorldObject obj) + ? ExpressionValue.Number(obj.ItemType) + : ExpressionValue.Zero, "wobjectgetinternaltype[object]"); + registry.Alias("getobjectinternaltype", "wobjectgetinternaltype"); + registry.Register("wobjecthasdata", 1, 1, (_, args) => + ExpressionValue.Boolean( + TryObject(objects, args[0], "wobjecthasdata", out PluginWorldObject obj) + && obj.HasAppraisalData), "wobjecthasdata[object]"); + registry.Register("wobjectlastidtime", 1, 1, (_, args) => + TryObject(objects, args[0], "wobjectlastidtime", out PluginWorldObject obj) + ? ExpressionValue.Number(obj.LastIdTime) + : ExpressionValue.Zero, + "wobjectlastidtime[object]"); + registry.Register("wobjectisvalid", 1, 1, (_, args) => + ExpressionValue.Boolean(TryObject( + objects, + args[0], + "wobjectisvalid", + out PluginWorldObject obj) && obj.HasPosition), + "wobjectisvalid[object]"); + registry.Register("wobjectrequestdata", 1, 1, (_, args) => + ExpressionValue.Boolean(objects.Identify( + args[0].AsObjectId("wobjectrequestdata")).Accepted), + "wobjectrequestdata[object]"); + registry.Register("wobjectgetisdooropen", 1, 1, (_, args) => + ExpressionValue.Boolean( + TryObject(objects, args[0], "wobjectgetisdooropen", out PluginWorldObject obj) + && obj.IsDoorOpen), "wobjectgetisdooropen[object]"); + registry.Register("wobjectgetphysicscoordinates", 1, 1, (_, args) => + TryObject(objects, args[0], "wobjectgetphysicscoordinates", out PluginWorldObject obj) + && obj.HasPosition + ? Coordinates(obj.Position) + : ExpressionValue.Zero, + "wobjectgetphysicscoordinates[object]"); + registry.Register("getheading", 1, 1, (_, args) => + TryObject(objects, args[0], "getheading", out PluginWorldObject obj) + && obj.HasPosition + ? ExpressionValue.Number(NormalizeHeading(obj.Position.HeadingDegrees)) + : ExpressionValue.Zero, "getheading[object]"); + registry.Register("getheadingto", 1, 1, (_, args) => + { + PluginNavigationSnapshot player = host.Automation.Navigation.Snapshot; + return player.IsAvailable + && TryObject(objects, args[0], "getheadingto", out PluginWorldObject obj) + && obj.HasPosition + ? ExpressionValue.Number(HeadingTo(player.Position, obj.Position)) + : ExpressionValue.Zero; + }, "getheadingto[object]"); + + RegisterObjectProperty(registry, host, "wobjectgetintprop", PropertyKind.Int); + RegisterObjectProperty(registry, host, "wobjectgetdoubleprop", PropertyKind.Double); + RegisterObjectProperty(registry, host, "wobjectgetboolprop", PropertyKind.Bool); + RegisterObjectProperty(registry, host, "wobjectgetstringprop", PropertyKind.String); + registry.Register("wobjectgetspellids", 1, 1, (_, args) => + TryObject(objects, args[0], "wobjectgetspellids", out PluginWorldObject obj) + ? NumberList(obj.SpellIds) + : ExpressionValue.List(new ExpressionList()), + "wobjectgetspellids[object]"); + registry.Register("wobjectgetactivespellids", 1, 1, (_, args) => + TryObject(objects, args[0], "wobjectgetactivespellids", out PluginWorldObject obj) + ? NumberList(obj.ActiveSpellIds) + : ExpressionValue.List(new ExpressionList()), + "wobjectgetactivespellids[object]"); + + RegisterObjectFinders(registry, host); + RegisterInventoryCounts(registry, host); + RegisterObjectVitals(registry, host); + } + + private static void RegisterObjectFinders( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + registry.Register("wobjectfindall", 0, 0, (_, _) => + ObjectList(host.Automation.Objects.CaptureObjects()), "wobjectfindall[]"); + RegisterFinder(registry, host, "wobjectfindallbyobjectclass", ObjectSet.All, + (obj, arg) => (int)obj.ObjectClass == arg.AsInt32()); + RegisterFinder(registry, host, "wobjectfindallbytemplatetype", ObjectSet.All, + (obj, arg) => obj.WeenieClassId == ToUInt(arg, "template type")); + RegisterRegexFinder(registry, host, "wobjectfindallbynamerx", ObjectSet.All); + RegisterFinder(registry, host, "wobjectfindallinventorybyobjectclass", ObjectSet.Inventory, + (obj, arg) => (int)obj.ObjectClass == arg.AsInt32()); + RegisterFinder(registry, host, "wobjectfindallinventorybytemplatetype", ObjectSet.Inventory, + (obj, arg) => obj.WeenieClassId == ToUInt(arg, "template type")); + RegisterRegexFinder(registry, host, "wobjectfindallinventorybynamerx", ObjectSet.Inventory); + RegisterFinder(registry, host, "wobjectfindalllandscapebyobjectclass", ObjectSet.Landscape, + (obj, arg) => (int)obj.ObjectClass == arg.AsInt32()); + RegisterFinder(registry, host, "wobjectfindalllandscapebytemplatetype", ObjectSet.Landscape, + (obj, arg) => obj.WeenieClassId == ToUInt(arg, "template type")); + RegisterRegexFinder(registry, host, "wobjectfindalllandscapebynamerx", ObjectSet.Landscape); + registry.Register("wobjectfindallinventory", 0, 0, (_, _) => ObjectList( + FilterSet(host.Automation.Objects.CaptureObjects(), ObjectSet.Inventory)), + "wobjectfindallinventory[]"); + registry.Register("wobjectfindalllandscape", 0, 0, (_, _) => ObjectList( + FilterSet(host.Automation.Objects.CaptureObjects(), ObjectSet.Landscape)), + "wobjectfindalllandscape[]"); + registry.Register("wobjectfindallbycontainer", 1, 1, (_, args) => + { + uint container = args[0].AsObjectId("wobjectfindallbycontainer"); + return ObjectList(host.Automation.Objects.CaptureObjects().Where( + obj => obj.ContainerObjectId == container)); + }, "wobjectfindallbycontainer[container]"); + registry.Register("wobjectfindininventorybyname", 1, 1, (_, args) => + FirstObject(host, ObjectSet.Inventory, obj => obj.Name.Equals( + args[0].AsString("wobjectfindininventorybyname"), + StringComparison.OrdinalIgnoreCase)), + "wobjectfindininventorybyname[name]"); + registry.Register("wobjectfindininventorybynamerx", 1, 1, (_, args) => + { + Regex regex = CreateRegex(args[0].AsString("wobjectfindininventorybynamerx")); + return FirstObject(host, ObjectSet.Inventory, obj => regex.IsMatch(obj.Name)); + }, "wobjectfindininventorybynamerx[pattern]"); + registry.Register("wobjectfindininventorybytemplatetype", 1, 1, (_, args) => + FirstObject(host, ObjectSet.Inventory, obj => + obj.WeenieClassId == ToUInt(args[0], "template type")), + "wobjectfindininventorybytemplatetype[templateType]"); + + RegisterNearest(registry, host, "wobjectfindnearestbyobjectclass", + (obj, args) => (int)obj.ObjectClass == args[0].AsInt32()); + RegisterNearest(registry, host, "wobjectfindnearestbytemplatetype", + (obj, args) => obj.WeenieClassId == ToUInt(args[0], "template type")); + RegisterNearest(registry, host, "wobjectfindnearestbynameandobjectclass", + (obj, args) => obj.Name.Equals(args[0].AsString(), StringComparison.OrdinalIgnoreCase) + && (int)obj.ObjectClass == args[1].AsInt32(), argumentCount: 2); + RegisterNearest(registry, host, "wobjectfindnearestdoor", + (obj, _) => obj.ObjectClass == PluginObjectClass.Door, argumentCount: 0); + RegisterNearest(registry, host, "wobjectfindnearestmonster", + (obj, _) => obj.ObjectClass == PluginObjectClass.Monster, argumentCount: 0); + } + + private static void RegisterInventoryCounts( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + registry.Register("getitemcountininventorybyname", 1, 1, (_, args) => + { + string name = args[0].AsString("getitemcountininventorybyname"); + return ExpressionValue.Number(host.Automation.Objects.CaptureObjects() + .Where(obj => obj.IsOwned && obj.Name.Equals( + name, + StringComparison.OrdinalIgnoreCase)) + .Sum(static obj => Math.Max(1, obj.StackSize))); + }, "getitemcountininventorybyname[name]"); + registry.Register("getitemcountininventorybynamerx", 1, 1, (_, args) => + { + Regex regex = CreateRegex(args[0].AsString("getitemcountininventorybynamerx")); + return ExpressionValue.Number(host.Automation.Objects.CaptureObjects() + .Where(obj => obj.IsOwned && regex.IsMatch(obj.Name)) + .Sum(static obj => Math.Max(1, obj.StackSize))); + }, "getitemcountininventorybynamerx[pattern]"); + registry.Register("getinventorycountbytemplatetype", 1, 1, (_, args) => + { + uint template = ToUInt(args[0], "getinventorycountbytemplatetype"); + return ExpressionValue.Number(host.Automation.Objects.CaptureObjects() + .Where(obj => obj.IsOwned && obj.WeenieClassId == template) + .Sum(static obj => Math.Max(1, obj.StackSize))); + }, "getinventorycountbytemplatetype[templateType]"); + registry.Register("getcontaineritemcount", 0, 1, (_, args) => + { + uint container = args.Count == 0 + ? host.Automation.Character.ObjectId + : args[0].AsObjectId("getcontaineritemcount"); + if (!host.Automation.Objects.TryGet(container, out PluginWorldObject obj) + || obj.ObjectClass is not (PluginObjectClass.Container or PluginObjectClass.Player)) + { + return ExpressionValue.Number(-1d); + } + return ExpressionValue.Number(host.Automation.Objects.CaptureObjects().Count( + item => item.ContainerObjectId == container)); + }, "getcontaineritemcount[container?]"); + registry.Register("getfreeitemslots", 0, 1, (_, args) => + ExpressionValue.Number(FreeSlots(host, args, containers: false)), + "getfreeitemslots[container?]"); + registry.Register("getfreecontainerslots", 0, 1, (_, args) => + ExpressionValue.Number(FreeSlots(host, args, containers: true)), + "getfreecontainerslots[container?]"); + } + + private static void RegisterObjectVitals( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + registry.Register("wobjectgethealth", 1, 1, (_, args) => + ExpressionValue.Number(ObjectVital(host, args[0], VitalObjectRead.Fraction)), + "wobjectgethealth[object]"); + registry.Register("wobjectgethealthvalue", 1, 1, (_, args) => + ExpressionValue.Number(ObjectVital(host, args[0], VitalObjectRead.Health)), + "wobjectgethealthvalue[object]"); + registry.Register("wobjectgetstaminavalue", 1, 1, (_, args) => + ExpressionValue.Number(ObjectVital(host, args[0], VitalObjectRead.Stamina)), + "wobjectgetstaminavalue[object]"); + registry.Register("wobjectgetmanavalue", 1, 1, (_, args) => + ExpressionValue.Number(ObjectVital(host, args[0], VitalObjectRead.Mana)), + "wobjectgetmanavalue[object]"); + } + + private static void RegisterActions( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + registry.Register("echo", 1, 1, (_, args) => + { + host.Automation.Chat.PostSystemMessage(args[0].ToDisplayString()); + return args[0]; + }, "echo[text]"); + registry.Register("chatbox", 1, 1, (_, args) => ExpressionValue.Boolean( + host.Automation.Chat.Submit(args[0].ToDisplayString())), "chatbox[text]"); + registry.Register("chatboxpaste", 1, 1, (_, args) => ExpressionValue.Boolean( + host.Automation.Chat.Submit(args[0].ToDisplayString())), "chatboxpaste[text]"); + registry.Register("actiontryselect", 1, 1, (_, args) => ExpressionValue.Boolean( + host.Selection.Select(args[0].AsObjectId("actiontryselect"))), + "actiontryselect[object]"); + registry.Register("actiontryuseitem", 1, 1, (_, args) => ExpressionValue.Boolean( + host.Automation.Items.Use(args[0].AsObjectId("actiontryuseitem")).Accepted), + "actiontryuseitem[object]"); + registry.Register("actiontryapplyitem", 2, 2, (_, args) => ExpressionValue.Boolean( + host.Automation.Items.Apply( + args[0].AsObjectId("actiontryapplyitem"), + args[1].AsObjectId("actiontryapplyitem")).Accepted), + "actiontryapplyitem[source,target]"); + registry.Register("actiontrygiveitem", 2, 3, (_, args) => ExpressionValue.Boolean( + host.Automation.Items.Give( + args[0].AsObjectId("actiontrygiveitem"), + args[1].AsObjectId("actiontrygiveitem"), + args.Count == 3 ? ToUInt(args[2], "actiontrygiveitem") : 0u).Accepted), + "actiontrygiveitem[item,target,amount?]"); + registry.Register("actiontrydrop", 1, 2, (_, args) => ExpressionValue.Boolean( + host.Automation.Items.Drop( + args[0].AsObjectId("actiontrydrop"), + args.Count == 2 ? ToUInt(args[1], "actiontrydrop") : 0u).Accepted), + "actiontrydrop[item,amount?]"); + registry.Register("actiontrymove", 2, 4, (_, args) => ExpressionValue.Boolean( + host.Automation.Items.MoveToContainer( + args[0].AsObjectId("actiontrymove"), + args[1].AsObjectId("actiontrymove"), + 0u, + args.Count >= 3 ? args[2].AsInt32("actiontrymove") : 0).Accepted), + "actiontrymove[item,destination,slot?,addToStack?]"); + registry.Register("actiontrysplit", 2, 3, (_, args) => + { + uint destination = args.Count == 3 + ? args[2].AsObjectId("actiontrysplit") + : host.Automation.Character.ObjectId; + return ExpressionValue.Boolean(host.Automation.Items.MoveToContainer( + args[0].AsObjectId("actiontrysplit"), + destination, + ToUInt(args[1], "actiontrysplit")).Accepted); + }, "actiontrysplit[item,newStackSize,destination?]"); + registry.Register("actiontrycastbyid", 1, 1, (_, args) => CastResult( + host.Automation.Magic, + ToUInt(args[0], "actiontrycastbyid"), + target: null), "actiontrycastbyid[spellId]"); + registry.Register("actiontrycastbyidontarget", 2, 2, (_, args) => CastResult( + host.Automation.Magic, + ToUInt(args[0], "actiontrycastbyidontarget"), + args[1].AsObjectId("actiontrycastbyidontarget")), + "actiontrycastbyidontarget[spellId,target]"); + registry.Register("actiontryequipanywand", 0, 0, (_, _) => + { + PluginEquipmentItem? wand = host.Automation.Equipment + .CaptureOwnedEquipment() + .FirstOrDefault(static item => + (item.ValidLocations & 0x01000000u) != 0u); + return wand is { ObjectId: > 0u } item + ? ExpressionValue.Boolean(item.IsEquipped + || host.Automation.Equipment.Equip(item.ObjectId).Accepted) + : ExpressionValue.Zero; + }, "actiontryequipanywand[]"); + } + + private static void RegisterFellowship( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + IFellowshipAutomation fellowship = host.Automation.Fellowship; + registry.Register("getfellowshipstatus", 0, 0, (_, _) => + ExpressionValue.Boolean(fellowship.IsInFellowship), + "getfellowshipstatus[]"); + registry.Register("getfellowshipname", 0, 0, (_, _) => + ExpressionValue.String(fellowship.Name), "getfellowshipname[]"); + registry.Register("getfellowshipcount", 0, 0, (_, _) => + ExpressionValue.Number(fellowship.MemberCount), "getfellowshipcount[]"); + registry.Register("getfellowshipleaderid", 0, 0, (_, _) => + ExpressionValue.Number(fellowship.LeaderObjectId), + "getfellowshipleaderid[]"); + registry.Register("getfellowid", 1, 1, (_, args) => + { + IReadOnlyList roster = fellowship.CaptureRoster(); + int index = args[0].AsInt32("getfellowid"); + return (uint)index < (uint)roster.Count + ? ExpressionValue.Number(roster[index].ObjectId) + : ExpressionValue.Zero; + }, "getfellowid[index]"); + registry.Register("getfellowname", 1, 1, (_, args) => + { + IReadOnlyList roster = fellowship.CaptureRoster(); + int index = args[0].AsInt32("getfellowname"); + return (uint)index < (uint)roster.Count + ? ExpressionValue.String(roster[index].Name) + : ExpressionValue.String(string.Empty); + }, "getfellowname[index]"); + registry.Register("getfellowshiplocked", 0, 0, (_, _) => + ExpressionValue.Boolean(fellowship.IsLocked), "getfellowshiplocked[]"); + registry.Register("getfellowshipisleader", 0, 0, (_, _) => + ExpressionValue.Boolean( + fellowship.IsInFellowship + && fellowship.LeaderObjectId == host.Automation.Character.ObjectId), + "getfellowshipisleader[]"); + registry.Register("getfellowshipisopen", 0, 0, (_, _) => + ExpressionValue.Boolean(fellowship.IsOpen), "getfellowshipisopen[]"); + registry.Register("getfellowshipisfull", 0, 0, (_, _) => + ExpressionValue.Boolean( + fellowship.IsInFellowship && fellowship.MemberCount == 9), + "getfellowshipisfull[]"); + registry.Register("getfellowshipcanrecruit", 0, 0, (_, _) => + ExpressionValue.Boolean( + fellowship.IsInFellowship + && (fellowship.IsOpen + || fellowship.LeaderObjectId == host.Automation.Character.ObjectId) + && fellowship.MemberCount < 9), + "getfellowshipcanrecruit[]"); + registry.Register("getfellownames", 0, 0, (_, _) => ExpressionValue.List( + new ExpressionList(fellowship.CaptureRoster().Select( + static member => ExpressionValue.String(member.Name)))), + "getfellownames[]"); + registry.Register("getfellowids", 0, 0, (_, _) => ExpressionValue.List( + new ExpressionList(fellowship.CaptureRoster().Select( + static member => ExpressionValue.Number(member.ObjectId)))), + "getfellowids[]"); + } + + private static void RegisterWorldTime( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + PluginWorldTimeSnapshot Snapshot() => host.Automation.WorldTime.Snapshot; + registry.Register("getgameyear", 0, 0, (_, _) => + ExpressionValue.Number(Snapshot().Year), "getgameyear[]"); + registry.Register("getgamemonth", 0, 0, (_, _) => + ExpressionValue.Number(Snapshot().Month), "getgamemonth[]"); + registry.Register("getgamemonthname", 1, 1, (_, args) => + { + int index = args[0].AsInt32("getgamemonthname"); + return (uint)index < (uint)GameMonthNames.Length + ? ExpressionValue.String(GameMonthNames[index]) + : ExpressionValue.String(string.Empty); + }, "getgamemonthname[monthIndex]"); + registry.Register("getgameday", 0, 0, (_, _) => + ExpressionValue.Number(Snapshot().Day), "getgameday[]"); + registry.Register("getgamehour", 0, 0, (_, _) => + ExpressionValue.Number(Snapshot().Hour), "getgamehour[]"); + registry.Register("getgamehourname", 1, 1, (_, args) => + { + int index = args[0].AsInt32("getgamehourname"); + return (uint)index < (uint)GameHourNames.Length + ? ExpressionValue.String(GameHourNames[index]) + : ExpressionValue.String(string.Empty); + }, "getgamehourname[hourIndex]"); + registry.Register("getminutesuntilday", 0, 0, (_, _) => + ExpressionValue.Number(Snapshot().MinutesUntilDay), + "getminutesuntilday[]"); + registry.Register("getminutesuntilnight", 0, 0, (_, _) => + ExpressionValue.Number(Snapshot().MinutesUntilNight), + "getminutesuntilnight[]"); + registry.Register("getgameticks", 0, 0, (_, _) => + ExpressionValue.Number(Snapshot().GameTicks), "getgameticks[]"); + registry.Register("getisday", 0, 0, (_, _) => + ExpressionValue.Boolean(Snapshot().IsDay), "getisday[]"); + registry.Register("getisnight", 0, 0, (_, _) => + ExpressionValue.Boolean(!Snapshot().IsDay), "getisnight[]"); + } + + private static void RegisterCombatAndMovement( + ExpressionFunctionRegistry registry, + IPluginHost host) + { + registry.Register("getcombatstate", 0, 0, (_, _) => ExpressionValue.String( + host.Automation.Combat.Snapshot.Mode.ToString()), "getcombatstate[]"); + registry.Register("setcombatstate", 1, 1, (_, args) => + { + if (!Enum.TryParse( + args[0].AsString("setcombatstate"), + ignoreCase: true, + out PluginCombatMode mode) + || mode == PluginCombatMode.Unknown) + { + return ExpressionValue.Zero; + } + return ExpressionValue.Boolean( + host.Automation.Combat.EnterMode(mode).Accepted); + }, "setcombatstate[state]"); + registry.Register("getbusystate", 0, 0, (_, _) => ExpressionValue.Number( + host.Automation.Items.IsBusy + || host.Automation.Equipment.IsBusy + || host.Automation.Magic.IsCasting ? 1d : 0d), "getbusystate[]"); + registry.Register("getequippedweapontype", 0, 0, (_, _) => + { + foreach (PluginEquipmentItem item in host.Automation.Equipment + .CaptureOwnedEquipment().Where(static item => item.IsEquipped)) + { + if ((item.EquippedLocation & 0x00400000u) != 0u) + return ExpressionValue.String("Missile"); + if ((item.EquippedLocation & 0x01000000u) != 0u) + return ExpressionValue.String("Wand"); + if ((item.EquippedLocation & 0x00100000u) != 0u) + return ExpressionValue.String("Melee"); + } + return ExpressionValue.String("None"); + }, "getequippedweapontype[]"); + registry.Register("setmotion", 2, 2, (_, args) => + { + string motion = args[0].AsString("setmotion"); + bool enabled = args[1].AsNumber("setmotion") != 0d; + PluginNavigationSnapshot snapshot = host.Automation.Navigation.Snapshot; + PluginMovementIntent intent = enabled + ? MotionIntent(motion) + : default; + PluginNavigationCommandStatus result = enabled + ? host.Automation.Navigation.SetMovementIntent(intent) + : host.Automation.Navigation.ClearMovementIntent(); + return ExpressionValue.Boolean(result == PluginNavigationCommandStatus.Accepted); + }, "setmotion[motion,state]"); + registry.Register("getmotion", 1, 1, (_, args) => + { + string motion = args[0].AsString("getmotion"); + PluginNavigationSnapshot snapshot = host.Automation.Navigation.Snapshot; + return ExpressionValue.Number(snapshot.IsMoving + && motion is not null ? 2d : 0d); + }, "getmotion[motion]"); + registry.Register("clearmotion", 0, 0, (_, _) => ExpressionValue.Boolean( + host.Automation.Navigation.ClearMovementIntent() + == PluginNavigationCommandStatus.Accepted), "clearmotion[]"); + } + + private static void RegisterObjectProperty( + ExpressionFunctionRegistry registry, + IPluginHost host, + string name, + PropertyKind kind) + { + registry.Register(name, 2, 2, (_, args) => + { + uint objectId = args[0].AsObjectId(name); + uint key = ToUInt(args[1], name); + if (!host.Automation.Objects.TryCaptureProperties( + objectId, + out PluginItemProperties properties)) + { + return ExpressionValue.Zero; + } + return kind switch + { + PropertyKind.Int => ExpressionValue.Number(Get(properties.Ints, key)), + PropertyKind.Double => ExpressionValue.Number(Get(properties.Floats, key)), + PropertyKind.Bool => ExpressionValue.Boolean(Get(properties.Bools, key)), + PropertyKind.String => properties.Strings.TryGetValue(key, out string? value) + ? ExpressionValue.String(value) + : ExpressionValue.Zero, + _ => ExpressionValue.Zero, + }; + }, $"{name}[object,property]"); + } + + private static void RegisterFinder( + ExpressionFunctionRegistry registry, + IPluginHost host, + string name, + ObjectSet set, + Func predicate) + { + registry.Register(name, 1, 1, (_, args) => ObjectList(FilterSet( + host.Automation.Objects.CaptureObjects(), + set).Where(obj => predicate(obj, args[0]))), $"{name}[value]"); + } + + private static void RegisterRegexFinder( + ExpressionFunctionRegistry registry, + IPluginHost host, + string name, + ObjectSet set) + { + registry.Register(name, 1, 1, (_, args) => + { + Regex regex = CreateRegex(args[0].AsString(name)); + return ObjectList(FilterSet( + host.Automation.Objects.CaptureObjects(), + set).Where(obj => regex.IsMatch(obj.Name))); + }, $"{name}[pattern]"); + } + + private static void RegisterNearest( + ExpressionFunctionRegistry registry, + IPluginHost host, + string name, + Func, bool> predicate, + int argumentCount = 1) + { + registry.Register(name, argumentCount, argumentCount, (_, args) => + { + PluginNavigationSnapshot player = host.Automation.Navigation.Snapshot; + if (!player.IsAvailable) + return ExpressionValue.Zero; + PluginWorldObject? nearest = host.Automation.Objects.CaptureObjects() + .Where(obj => obj.IsLandscape && obj.HasPosition && predicate(obj, args)) + .OrderBy(obj => player.Position.HorizontalDistanceMeters(obj.Position)) + .ThenBy(static obj => obj.ObjectId) + .Cast() + .FirstOrDefault(); + return nearest is { } found + ? ExpressionValue.WorldObject(found.ObjectId) + : ExpressionValue.Zero; + }, $"{name}[...]" ); + } + + private static ExpressionValue FirstObject( + IPluginHost host, + ObjectSet set, + Func predicate) + { + PluginWorldObject? found = FilterSet( + host.Automation.Objects.CaptureObjects(), set) + .Where(predicate) + .OrderBy(static obj => obj.ObjectId) + .Cast() + .FirstOrDefault(); + return found is { } value + ? ExpressionValue.WorldObject(value.ObjectId) + : ExpressionValue.Zero; + } + + private static IEnumerable FilterSet( + IEnumerable objects, + ObjectSet set) => set switch + { + ObjectSet.Inventory => objects.Where(static obj => obj.IsOwned), + ObjectSet.Landscape => objects.Where(static obj => obj.IsLandscape), + _ => objects, + }; + + private static ExpressionValue ObjectList(IEnumerable objects) => + ExpressionValue.List(new ExpressionList(objects + .OrderBy(static obj => obj.ObjectId) + .Select(static obj => ExpressionValue.WorldObject(obj.ObjectId)))); + + private static ExpressionValue NumberList(IEnumerable values) => + ExpressionValue.List(new ExpressionList(values.Select( + static value => ExpressionValue.Number(value)))); + + private static bool TryObject( + IWorldObjectAutomation objects, + in ExpressionValue value, + string operation, + out PluginWorldObject obj) => objects.TryGet( + value.AsObjectId(operation), + out obj); + + private static bool TryPlayerProperties( + IPluginHost host, + out PluginItemProperties properties) => + host.Automation.Objects.TryCaptureProperties( + host.Automation.Character.ObjectId, + out properties); + + private static double PlayerProperty( + IPluginHost host, + in ExpressionValue keyValue, + PropertyKind kind) + { + if (!TryPlayerProperties(host, out PluginItemProperties properties)) + return 0d; + uint key = ToUInt(keyValue, "character property"); + return kind switch + { + PropertyKind.Int => Get(properties.Ints, key), + PropertyKind.Int64 => Get(properties.Int64s, key), + PropertyKind.Double => Get(properties.Floats, key), + PropertyKind.Bool => Get(properties.Bools, key) ? 1d : 0d, + _ => 0d, + }; + } + + private static double Attribute( + ICharacterInfo character, + in ExpressionValue id, + bool buffed) + { + int kind = id.AsInt32("character attribute") - 1; + foreach (PluginAttributeInfo attribute in character.Attributes) + { + if (attribute.Kind == kind) + return buffed ? attribute.Current : attribute.Base; + } + return 0d; + } + + private static double Skill( + ICharacterInfo character, + in ExpressionValue id, + SkillRead read) + { + if (!character.TryGetSkill(ToUInt(id, "character skill"), out PluginSkillInfo skill)) + return 0d; + return read switch + { + SkillRead.Base => skill.Base, + SkillRead.Buffed => skill.Current, + SkillRead.Training => skill.Training switch + { + PluginSkillTraining.Untrained => 1d, + PluginSkillTraining.Trained => 2d, + PluginSkillTraining.Specialized => 3d, + _ => 0d, + }, + _ => 0d, + }; + } + + private static double Vital( + ICharacterInfo character, + in ExpressionValue id, + VitalRead read) + { + (uint current, uint maximum) = id.AsInt32("character vital") switch + { + 1 => (character.CurrentHealth, character.MaxHealth), + 2 => (character.CurrentStamina, character.MaxStamina), + 3 => (character.CurrentMana, character.MaxMana), + _ => (0u, 0u), + }; + return read == VitalRead.Current ? current : maximum; + } + + private static double ObjectVital( + IPluginHost host, + in ExpressionValue objectValue, + VitalObjectRead read) + { + uint id = objectValue.AsObjectId("object vital"); + ICharacterInfo character = host.Automation.Character; + if (id == character.ObjectId) + { + return read switch + { + VitalObjectRead.Fraction => character.MaxHealth == 0u + ? -1d : character.CurrentHealth / (double)character.MaxHealth, + VitalObjectRead.Health => character.CurrentHealth, + VitalObjectRead.Stamina => character.CurrentStamina, + VitalObjectRead.Mana => character.CurrentMana, + _ => -1d, + }; + } + if (read is VitalObjectRead.Fraction or VitalObjectRead.Health) + { + foreach (PluginCombatTarget target in host.Automation.Combat + .CaptureHostileTargets(float.MaxValue)) + { + if (target.ObjectId != id || !target.IsHealthKnown) + continue; + return read == VitalObjectRead.Fraction + ? target.HealthFraction + : target.MaximumHealth > 0 + ? target.HealthFraction * target.MaximumHealth + : -1d; + } + } + return -1d; + } + + private static double FreeSlots( + IPluginHost host, + IReadOnlyList args, + bool containers) + { + uint containerId = args.Count == 0 + ? host.Automation.Character.ObjectId + : args[0].AsObjectId("free slots"); + if (!host.Automation.Objects.TryGet(containerId, out PluginWorldObject container) + || container.ObjectClass is not (PluginObjectClass.Container or PluginObjectClass.Player)) + { + return -1d; + } + IReadOnlyList all = host.Automation.Objects.CaptureObjects(); + int used = all.Count(item => item.ContainerObjectId == containerId + && (item.ObjectClass == PluginObjectClass.Container) == containers); + int capacity = containers + ? container.ContainersCapacity + : container.ItemsCapacity; + return Math.Max(0, capacity - used); + } + + private static ExpressionValue CastResult( + IMagicCommands magic, + uint spellId, + uint? target) + { + PluginCastGate gate = target is uint objectId + ? magic.EvaluateGate(spellId, objectId) + : magic.EvaluateGate(spellId); + if (gate == PluginCastGate.Ready) + { + bool started = target is uint id + ? magic.Cast(spellId, id) + : magic.Cast(spellId); + return ExpressionValue.Number(started ? 1d : 0d); + } + return ExpressionValue.Number(gate is PluginCastGate.NotKnown + or PluginCastGate.Unavailable + or PluginCastGate.Refused ? 2d : 0d); + } + + private static ExpressionValue SpellProperty( + in PluginSpellInfo spell, + string property) => property.Trim().ToLowerInvariant() switch + { + "id" or "spellid" => ExpressionValue.Number(spell.SpellId), + "name" => ExpressionValue.String(spell.Name), + "family" => ExpressionValue.Number(spell.Family), + "generation" or "tier" => ExpressionValue.Number(spell.Tier), + "difficulty" => ExpressionValue.Number(spell.Difficulty), + "quality" => ExpressionValue.Number(spell.Quality), + "manacost" => ExpressionValue.Number(spell.ManaCost), + "duration" => ExpressionValue.Number(spell.DurationSeconds), + "school" => ExpressionValue.Number(spell.School), + "description" => ExpressionValue.String(spell.Description), + "isbeneficial" => ExpressionValue.Boolean(spell.IsBeneficial), + "isoffensive" => ExpressionValue.Boolean(spell.IsOffensive), + "isdebuff" => ExpressionValue.Boolean(spell.IsDebuff), + "spelltype" => ExpressionValue.Number(spell.SpellType), + "flags" => ExpressionValue.Number(spell.RawFlags), + "targetmask" => ExpressionValue.Number(spell.TargetMask), + _ => ExpressionValue.Zero, + }; + + private static double SpellExpiration( + IReadOnlyList enchantments, + uint spellId) + { + foreach (PluginActiveEnchantment enchantment in enchantments) + { + if (enchantment.SpellId == spellId) + return enchantment.SecondsRemaining; + } + return 0d; + } + + private static PluginMovementIntent MotionIntent(string motion) => + motion.Trim().ToLowerInvariant() switch + { + "forward" => new PluginMovementIntent(Forward: true), + "backward" or "backup" => new PluginMovementIntent(Backward: true), + "turnright" => new PluginMovementIntent(TurnRight: true), + "turnleft" => new PluginMovementIntent(TurnLeft: true), + "straferight" => new PluginMovementIntent(StrafeRight: true), + "strafeleft" => new PluginMovementIntent(StrafeLeft: true), + "walk" => new PluginMovementIntent(Forward: true, Run: false), + _ => throw new ExpressionEvaluationException( + $"Invalid motion '{motion}'."), + }; + + private static ExpressionValue Coordinates(in PluginNavigationPosition position) => + ExpressionValue.Coordinates(new ExpressionCoordinates( + position.EastWest, + position.NorthSouth, + position.Elevation / 240d)); + + private static double HeadingTo( + in PluginNavigationPosition from, + in PluginNavigationPosition to) + { + double east = to.EastWest - from.EastWest; + double north = to.NorthSouth - from.NorthSouth; + return NormalizeHeading(Math.Atan2(east, north) * 180d / Math.PI); + } + + private static double NormalizeHeading(double heading) + { + double result = heading % 360d; + return result < 0d ? result + 360d : result; + } + + private static Regex CreateRegex(string pattern) => new( + pattern, + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, + RegexTimeout); + + private static uint ToUInt(in ExpressionValue value, string operation) => + checked((uint)value.AsNumber(operation)); + + private static double Get(IReadOnlyDictionary values, uint key) => + values.TryGetValue(key, out int value) ? value : 0d; + + private static double Get(IReadOnlyDictionary values, uint key) => + values.TryGetValue(key, out long value) ? value : 0d; + + private static double Get( + IReadOnlyDictionary values, + uint key, + double fallback = 0d) => + values.TryGetValue(key, out double value) ? value : fallback; + + private static bool Get(IReadOnlyDictionary values, uint key) => + values.TryGetValue(key, out bool value) && value; + + /// Legacy .NET Framework ordinal string hash used by UtilityBelt. + private static int LegacyStringHash(string value) + { + unchecked + { + int hash1 = 5381; + int hash2 = hash1; + for (int index = 0; index < value.Length; index += 2) + { + hash1 = ((hash1 << 5) + hash1) ^ value[index]; + if (index == value.Length - 1) + break; + hash2 = ((hash2 << 5) + hash2) ^ value[index + 1]; + } + return hash1 + hash2 * 1566083941; + } + } + + private enum PropertyKind { Int, Int64, Double, Bool, String } + private enum SkillRead { Base, Buffed, Training } + private enum VitalRead { Current, Maximum } + private enum VitalObjectRead { Fraction, Health, Stamina, Mana } + private enum ObjectSet { All, Inventory, Landscape } +} diff --git a/src/AcDream.Plugins.MossTank/Expressions/MossTankExpressionRuntime.cs b/src/AcDream.Plugins.MossTank/Expressions/MossTankExpressionRuntime.cs new file mode 100644 index 00000000..0c28a348 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Expressions/MossTankExpressionRuntime.cs @@ -0,0 +1,429 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank.Expressions; + +/// +/// One shared expression lifetime for MossTank commands and Meta. Session, +/// persistent, and world-global variables therefore mean the same thing from +/// every entry point, just as they do in UtilityBelt. +/// +internal sealed class MossTankExpressionRuntime : IDisposable +{ + private const int DefaultInstructionBudget = 10_000; + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + }; + + private readonly IPluginHost _host; + private readonly ExpressionState _state = new(); + private readonly ExpressionFunctionRegistry _functions; + private readonly ExperienceMeter _experience; + private readonly QuestTracker _quests; + private readonly SalvageStagingManager _salvage; + private readonly StatusHudManager _statusHud; + private readonly List _delayed = []; + private int _nextDelayId = 1; + private string _identity = string.Empty; + private string? _persistentJson; + private string? _globalJson; + private bool _disposed; + + public MossTankExpressionRuntime(IPluginHost host, Random? random = null) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _experience = new ExperienceMeter(host); + _quests = new QuestTracker(host); + _salvage = new SalvageStagingManager(host); + _statusHud = new StatusHudManager(host); + _functions = CoreExpressionFunctions.CreateDefault(random); + HostExpressionFunctions.Register(_functions, host); + RegisterExperienceFunctions(); + RegisterQuestFunctions(); + RegisterSalvageFunctions(); + RegisterStatusHudFunctions(); + RegisterExecutionFunctions(); + BindIdentity(force: true); + } + + public ExpressionState State => _state; + internal ExpressionFunctionRegistry Registry => _functions; + public IReadOnlyCollection Functions => _functions.Functions; + public int PendingExecutionCount => _delayed.Count; + + public ExpressionValue Evaluate( + string source, + int instructionBudget = DefaultInstructionBudget, + CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + BindIdentity(force: false); + ExpressionProgram program = ExpressionProgram.Compile(source); + var context = new ExpressionEvaluationContext( + _state, + _functions, + instructionBudget, + cancellationToken); + ExpressionValue result = program.Evaluate(context); + FlushVariables(); + return result; + } + + public void OnTick(double elapsedSeconds) + { + ObjectDisposedException.ThrowIf(_disposed, this); + BindIdentity(force: false); + if (elapsedSeconds < 0d || !double.IsFinite(elapsedSeconds)) + throw new ArgumentOutOfRangeException(nameof(elapsedSeconds)); + _experience.OnTick(elapsedSeconds); + _quests.OnTick(elapsedSeconds); + if (_delayed.Count == 0) + return; + + double elapsedMilliseconds = elapsedSeconds * 1000d; + for (int index = 0; index < _delayed.Count; index++) + _delayed[index] = _delayed[index] with + { + RemainingMilliseconds = + _delayed[index].RemainingMilliseconds - elapsedMilliseconds, + }; + + DelayedExpression[] ready = _delayed + .Where(static delayed => delayed.RemainingMilliseconds <= 0d) + .OrderBy(static delayed => delayed.Id) + .ToArray(); + if (ready.Length == 0) + return; + _delayed.RemoveAll(static delayed => delayed.RemainingMilliseconds <= 0d); + foreach (DelayedExpression delayed in ready) + { + try + { + Evaluate(delayed.Source); + } + catch (Exception error) + { + _host.Log.Error( + $"Delayed expression {delayed.Id} failed: {error.Message}"); + } + } + } + + public void ClearSession() + { + _state.Clear(ExpressionVariableScope.Session); + _delayed.Clear(); + } + + public void DestroyAuxiliaryViews() => _statusHud.Destroy(); + + public void Dispose() + { + if (_disposed) + return; + FlushVariables(); + _delayed.Clear(); + _statusHud.Destroy(); + _disposed = true; + } + + private void RegisterExecutionFunctions() + { + _functions.Register("exec", 1, 1, (context, args) => + ExpressionProgram.Compile(args[0].AsString("exec")).Evaluate(context), + "exec[expression]"); + _functions.Register("delayexec", 2, 2, (_, args) => + { + double delay = Math.Max(0d, args[0].AsNumber("delayexec")); + string source = args[1].AsString("delayexec"); + int id = NextDelayId(); + _delayed.Add(new DelayedExpression(id, delay, source)); + return ExpressionValue.Number(id); + }, "delayexec[milliseconds,expression]"); + _functions.Register("clearexec", 1, 1, (_, args) => + { + int id = args[0].AsInt32("clearexec"); + return ExpressionValue.Boolean( + _delayed.RemoveAll(delayed => delayed.Id == id) != 0); + }, "clearexec[id]"); + } + + private void RegisterExperienceFunctions() + { + _functions.Register("xpreset", 0, 0, (_, _) => + { + _experience.Reset(); + return ExpressionValue.One; + }, "xpreset[]"); + _functions.Register("xpmeter", 0, 0, (_, _) => + ExpressionValue.String(_experience.Format()), "xpmeter[]"); + _functions.Register("xpduration", 0, 0, (_, _) => + ExpressionValue.Number(_experience.DurationSeconds), "xpduration[]"); + _functions.Register("xptotal", 0, 0, (_, _) => + ExpressionValue.Number(_experience.Experience), "xptotal[]"); + _functions.Register("lumtotal", 0, 0, (_, _) => + ExpressionValue.Number(_experience.Luminance), "lumtotal[]"); + _functions.Register("xpavg", 0, 0, (_, _) => + ExpressionValue.Number(_experience.ExperiencePerHour), "xpavg[]"); + _functions.Register("lumavg", 0, 0, (_, _) => + ExpressionValue.Number(_experience.LuminancePerHour), "lumavg[]"); + } + + private void RegisterQuestFunctions() + { + _functions.Register("testquestflag", 1, 1, (_, args) => + ExpressionValue.Boolean(_quests.HasCompleted( + args[0].AsString("testquestflag"))), "testquestflag[questflag]"); + _functions.Register("getqueststatus", 1, 1, (_, args) => + ExpressionValue.Boolean(_quests.IsReady( + args[0].AsString("getqueststatus"))), "getqueststatus[questflag]"); + _functions.Register("getquestktprogress", 1, 1, (_, args) => + ExpressionValue.Number(_quests.Progress( + args[0].AsString("getquestktprogress"))), + "getquestktprogress[questflag]"); + _functions.Register("getquestktrequired", 1, 1, (_, args) => + ExpressionValue.Number(_quests.Required( + args[0].AsString("getquestktrequired"))), + "getquestktrequired[questflag]"); + _functions.Register("isrefreshingquests", 0, 0, (_, _) => + ExpressionValue.Boolean(_quests.IsRefreshing), "isrefreshingquests[]"); + } + + private void RegisterSalvageFunctions() + { + _functions.Register("ustadd", 1, 1, (_, args) => + ExpressionValue.Boolean(_salvage.Add( + args[0].AsObjectId("ustadd"))), "ustadd[object]"); + _functions.Register("ustopen", 0, 0, (_, _) => + ExpressionValue.Boolean(_salvage.Open()), "ustopen[]"); + _functions.Register("ustsalvage", 0, 0, (_, _) => + ExpressionValue.Boolean(_salvage.Salvage()), "ustsalvage[]"); + } + + private void RegisterStatusHudFunctions() + { + _functions.Register("statushud", 2, 2, (_, args) => + ExpressionValue.Boolean(_statusHud.Update( + args[0].AsString("statushud"), + args[1].ToDisplayString())), + "statushud[key,value]"); + _functions.Register("statushudcolored", 3, 3, (_, args) => + ExpressionValue.Boolean(_statusHud.Update( + args[0].AsString("statushudcolored"), + args[1].ToDisplayString(), + checked((uint)args[2].AsNumber("statushudcolored")))), + "statushudcolored[key,value,rgb]"); + } + + private int NextDelayId() + { + int initial = _nextDelayId; + do + { + int candidate = _nextDelayId++; + if (_nextDelayId <= 0) + _nextDelayId = 1; + if (_delayed.All(delayed => delayed.Id != candidate)) + return candidate; + } + while (_nextDelayId != initial); + throw new ExpressionEvaluationException("No delayed-expression ids remain"); + } + + private void BindIdentity(bool force) + { + ICharacterInfo character = _host.Automation.Character; + string identity = string.Join( + '\n', + character.WorldName, + character.AccountName, + character.Name); + if (!force && identity.Equals(_identity, StringComparison.Ordinal)) + return; + if (_identity.Length != 0) + FlushVariables(); + _identity = identity; + _quests.BindIdentity(identity); + _salvage.Clear(); + _state.Clear(ExpressionVariableScope.Session); + _delayed.Clear(); + _experience.Reset(); + _persistentJson = LoadScope(ExpressionVariableScope.Persistent); + _globalJson = LoadScope(ExpressionVariableScope.Global); + } + + private string? LoadScope(ExpressionVariableScope scope) + { + _state.Clear(scope); + if (!_host.Storage.IsAvailable || _identity.Length == 0) + return null; + try + { + string? json = _host.Storage.ReadText(StorageKey(scope)); + if (string.IsNullOrWhiteSpace(json)) + return null; + Dictionary? document = JsonSerializer.Deserialize< + Dictionary>(json, JsonOptions); + if (document is not null) + { + _state.Replace(scope, document.Select(static pair => + new KeyValuePair( + pair.Key, + Restore(pair.Value)))); + } + return json; + } + catch (Exception error) + { + _host.Log.Error($"Unable to load {scope} expression variables: {error.Message}"); + return null; + } + } + + private void FlushVariables() + { + if (!_host.Storage.IsAvailable || _identity.Length == 0) + return; + _persistentJson = FlushScope( + ExpressionVariableScope.Persistent, + _persistentJson); + _globalJson = FlushScope(ExpressionVariableScope.Global, _globalJson); + } + + private string? FlushScope(ExpressionVariableScope scope, string? previous) + { + try + { + Dictionary document = _state.Capture(scope) + .ToDictionary( + static pair => pair.Key, + static pair => Store(pair.Value), + StringComparer.OrdinalIgnoreCase); + string json = JsonSerializer.Serialize(document, JsonOptions); + if (!json.Equals(previous, StringComparison.Ordinal)) + _host.Storage.WriteText(StorageKey(scope), json); + return json; + } + catch (Exception error) + { + _host.Log.Error($"Unable to save {scope} expression variables: {error.Message}"); + return previous; + } + } + + private string StorageKey(ExpressionVariableScope scope) + { + ICharacterInfo character = _host.Automation.Character; + string owner = scope == ExpressionVariableScope.Persistent + ? string.Join('\n', character.WorldName, character.AccountName, character.Name) + : string.Join('\n', character.WorldName, character.AccountName); + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(owner)); + return $"expressions/{scope.ToString().ToLowerInvariant()}/" + + $"{Convert.ToHexString(hash.AsSpan(0, 12)).ToLowerInvariant()}.json"; + } + + private static StoredValue Store(in ExpressionValue value) => value.Kind switch + { + ExpressionValueKind.Number => new StoredValue + { + Kind = "number", + Number = value.AsNumber(), + }, + ExpressionValueKind.Boolean => new StoredValue + { + Kind = "boolean", + Number = value.AsNumber(), + }, + ExpressionValueKind.String => new StoredValue + { + Kind = "string", + Text = value.AsString(), + }, + ExpressionValueKind.List => new StoredValue + { + Kind = "list", + List = value.AsList().Items.Select(static item => Store(item)).ToList(), + }, + ExpressionValueKind.Dictionary => new StoredValue + { + Kind = "dictionary", + Dictionary = value.AsDictionary().Items.ToDictionary( + static pair => pair.Key, + static pair => Store(pair.Value), + StringComparer.Ordinal), + }, + ExpressionValueKind.Coordinates => StoreCoordinates(value.AsCoordinates()), + ExpressionValueKind.WorldObject => new StoredValue + { + Kind = "worldobject", + Number = value.AsObjectId(), + }, + _ => throw new ExpressionEvaluationException( + $"{value.Kind} values cannot be persisted"), + }; + + private static StoredValue StoreCoordinates(in ExpressionCoordinates value) => new() + { + Kind = "coordinates", + Coordinates = + [ + value.EastWest, + value.NorthSouth, + value.Elevation, + ], + }; + + private static ExpressionValue Restore(StoredValue value) => + value.Kind.ToLowerInvariant() switch + { + "number" => ExpressionValue.Number(value.Number), + "boolean" => ExpressionValue.Boolean(value.Number != 0d), + "string" => ExpressionValue.String(value.Text), + "list" => ExpressionValue.List(new ExpressionList( + (value.List ?? []).Select(Restore))), + "dictionary" => RestoreDictionary(value.Dictionary), + "coordinates" => RestoreCoordinates(value.Coordinates), + "worldobject" => ExpressionValue.WorldObject(checked((uint)value.Number)), + _ => ExpressionValue.Zero, + }; + + private static ExpressionValue RestoreDictionary( + Dictionary? values) + { + var result = new ExpressionDictionary(); + if (values is not null) + { + foreach ((string key, StoredValue value) in values) + result.Items[key] = Restore(value); + } + return ExpressionValue.Dictionary(result); + } + + private static ExpressionValue RestoreCoordinates(double[]? values) => + values is { Length: >= 2 } + ? ExpressionValue.Coordinates(new ExpressionCoordinates( + values[0], + values[1], + values.Length >= 3 ? values[2] : 0d)) + : ExpressionValue.Zero; + + private sealed class StoredValue + { + public string Kind { get; set; } = "number"; + public double Number { get; set; } + public string Text { get; set; } = string.Empty; + public List? List { get; set; } + public Dictionary? Dictionary { get; set; } + public double[]? Coordinates { get; set; } + } + + private readonly record struct DelayedExpression( + int Id, + double RemainingMilliseconds, + string Source); +} diff --git a/src/AcDream.Plugins.MossTank/Expressions/QuestTracker.cs b/src/AcDream.Plugins.MossTank/Expressions/QuestTracker.cs new file mode 100644 index 00000000..b2e47cd7 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Expressions/QuestTracker.cs @@ -0,0 +1,152 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank.Expressions; + +/// +/// UtilityBelt-compatible /myquests cache. The server remains authoritative; +/// this owner only parses the same lines UB consumes and never invents flags. +/// +internal sealed partial class QuestTracker(IPluginHost host) +{ + private const double CompletionSilenceSeconds = 1d; + private const double RetrySeconds = 15d; + private const int MaximumAttempts = 3; + + private readonly Dictionary _flags = + new(StringComparer.OrdinalIgnoreCase); + private ulong _chatSequence; + private string _identity = string.Empty; + private double _silenceSeconds; + private int _attemptsRemaining; + private bool _receivedFlag; + + public bool IsRefreshing { get; private set; } + public int Count => _flags.Count; + + public void BindIdentity(string identity) + { + if (identity.Equals(_identity, StringComparison.Ordinal)) + return; + _identity = identity; + _flags.Clear(); + IsRefreshing = false; + _receivedFlag = false; + _silenceSeconds = 0d; + if (!string.IsNullOrWhiteSpace(identity)) + Refresh(); + } + + public void Refresh() + { + if (IsRefreshing) + return; + _flags.Clear(); + _attemptsRemaining = MaximumAttempts; + _receivedFlag = false; + _silenceSeconds = 0d; + IsRefreshing = true; + SubmitRequest(); + } + + public void OnTick(double elapsedSeconds) + { + CaptureChat(); + if (!IsRefreshing) + return; + _silenceSeconds += elapsedSeconds; + if (_receivedFlag && _silenceSeconds > CompletionSilenceSeconds) + { + IsRefreshing = false; + return; + } + if (!_receivedFlag && _silenceSeconds > RetrySeconds) + SubmitRequest(); + } + + public bool HasCompleted(string key) => + _flags.ContainsKey(Normalize(key)); + + public bool IsReady(string key) + { + if (!_flags.TryGetValue(Normalize(key), out QuestFlag flag)) + return true; + DateTimeOffset next = flag.CompletedOn.AddSeconds(flag.RepeatSeconds); + if (next > DateTimeOffset.UtcNow) + return false; + return !(flag.MaxSolves == 1 && flag.Solves <= 1); + } + + public int Progress(string key) => + _flags.TryGetValue(Normalize(key), out QuestFlag flag) ? flag.Solves : 0; + + public int Required(string key) => + _flags.TryGetValue(Normalize(key), out QuestFlag flag) ? flag.MaxSolves : 0; + + private void CaptureChat() + { + foreach (PluginChatMessage message in host.Automation.Chat + .CaptureMessages(_chatSequence).OrderBy(static message => message.Sequence)) + { + _chatSequence = Math.Max(_chatSequence, message.Sequence); + string text = message.Text.Trim(); + if (text.Equals("Quest list is empty.", StringComparison.Ordinal) + || text.Equals( + "The command \"myquests\" is not currently enabled on this server.", + StringComparison.Ordinal)) + { + IsRefreshing = false; + continue; + } + Match match = MyQuestLine().Match(text); + if (!match.Success) + continue; + if (!int.TryParse(match.Groups["solves"].Value, + NumberStyles.Integer, CultureInfo.InvariantCulture, out int solves) + || !long.TryParse(match.Groups["completedOn"].Value, + NumberStyles.Integer, CultureInfo.InvariantCulture, out long completed)) + { + continue; + } + _ = int.TryParse(match.Groups["maxSolves"].Value, + NumberStyles.Integer, CultureInfo.InvariantCulture, out int maximum); + _ = long.TryParse(match.Groups["repeatTime"].Value, + NumberStyles.Integer, CultureInfo.InvariantCulture, out long repeat); + string key = Normalize(match.Groups["key"].Value); + _flags[key] = new QuestFlag( + solves, + maximum, + DateTimeOffset.FromUnixTimeSeconds(Math.Max(0L, completed)), + Math.Max(0L, repeat)); + _receivedFlag = true; + _silenceSeconds = 0d; + } + } + + private void SubmitRequest() + { + if (_attemptsRemaining <= 0) + { + IsRefreshing = false; + return; + } + _attemptsRemaining--; + _silenceSeconds = 0d; + host.Automation.Chat.Submit("/myquests"); + } + + private static string Normalize(string key) => key.Trim().ToLowerInvariant(); + + [GeneratedRegex( + "(?\\S+) \\- (?\\d+) solves \\((?\\d{0,11})\\)\"?((?.*)\" (?.*) (?\\d{0,11}))?.*$", + RegexOptions.CultureInvariant, + 100)] + private static partial Regex MyQuestLine(); + + private readonly record struct QuestFlag( + int Solves, + int MaxSolves, + DateTimeOffset CompletedOn, + long RepeatSeconds); +} diff --git a/src/AcDream.Plugins.MossTank/Expressions/SalvageStagingManager.cs b/src/AcDream.Plugins.MossTank/Expressions/SalvageStagingManager.cs new file mode 100644 index 00000000..a1fcbed8 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Expressions/SalvageStagingManager.cs @@ -0,0 +1,59 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank.Expressions; + +/// UtilityBelt UST expression staging over the canonical salvage command. +internal sealed class SalvageStagingManager(IPluginHost host) +{ + private readonly HashSet _staged = []; + + public int Count => _staged.Count; + + public bool Add(uint objectId) + { + if (!host.Automation.Items.CaptureOwnedItems() + .Any(item => item.ObjectId == objectId && !item.IsEquipped)) + { + return false; + } + _staged.Add(objectId); + return true; + } + + public bool Open() + { + PluginInventoryItem? ust = host.Automation.Items.CaptureOwnedItems() + .Where(static item => item.Name.Equals("Ust", StringComparison.Ordinal)) + .OrderBy(static item => item.ObjectId) + .Cast() + .FirstOrDefault(); + return ust is { } found + && host.Automation.Items.Use(found.ObjectId).Accepted; + } + + public bool Salvage() + { + IReadOnlyList inventory = + host.Automation.Items.CaptureOwnedItems(); + PluginInventoryItem? ust = inventory + .Where(static item => item.Name.Equals("Ust", StringComparison.Ordinal)) + .OrderBy(static item => item.ObjectId) + .Cast() + .FirstOrDefault(); + if (ust is not { } tool) + return false; + uint[] items = inventory + .Where(item => item.ObjectId != tool.ObjectId && _staged.Contains(item.ObjectId)) + .Select(static item => item.ObjectId) + .ToArray(); + if (items.Length == 0 + || !host.Automation.Items.Salvage(tool.ObjectId, items).Accepted) + { + return false; + } + _staged.Clear(); + return true; + } + + public void Clear() => _staged.Clear(); +} diff --git a/src/AcDream.Plugins.MossTank/Expressions/StatusHudManager.cs b/src/AcDream.Plugins.MossTank/Expressions/StatusHudManager.cs new file mode 100644 index 00000000..0fba5a42 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Expressions/StatusHudManager.cs @@ -0,0 +1,68 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank.Expressions; + +/// VTank Meta status HUD backed by one shelf-managed plugin window. +internal sealed class StatusHudManager(IPluginHost host) +{ + private const uint DefaultColor = 0xE8DEC3u; + private const string Markup = """ + + + """; + + private readonly Dictionary _entries = + new(StringComparer.Ordinal); + private readonly StatusBinding _binding = new(); + private IDisposable? _registration; + + public int Count => _entries.Count; + internal IReadOnlyList Rows => _binding.Rows; + internal IReadOnlyList RowColors => _binding.RowColors; + + public bool Update(string key, string value, uint? color = null) + { + if (string.IsNullOrEmpty(key)) + return false; + _entries[key] = new StatusEntry(value ?? string.Empty, color ?? DefaultColor); + _binding.Rows = _entries.Select(static pair => + $"{pair.Key}: {pair.Value.Value}").ToArray(); + _binding.RowColors = _entries.Select(static pair => pair.Value.Color).ToArray(); + if (_registration is null && host.HasUi) + { + _registration = host.Ui.RegisterPanelContent( + new PluginPanelDescriptor("vtank-meta-status", "VTank Meta Status") + { + IconText = "S", + StartVisible = true, + ShowInSidePanel = true, + }, + Markup, + _binding); + } + return true; + } + + public void Destroy() + { + _registration?.Dispose(); + _registration = null; + _entries.Clear(); + _binding.Rows = []; + _binding.RowColors = []; + } + + private readonly record struct StatusEntry(string Value, uint Color); + + private sealed class StatusBinding + { + public bool WindowAvailable => true; + public IReadOnlyList Rows { get; internal set; } = []; + public IReadOnlyList RowColors { get; internal set; } = []; + public int SelectedRow => -1; + } +} diff --git a/src/AcDream.Plugins.MossTank/FellowshipManager.cs b/src/AcDream.Plugins.MossTank/FellowshipManager.cs new file mode 100644 index 00000000..8ec1c4cd --- /dev/null +++ b/src/AcDream.Plugins.MossTank/FellowshipManager.cs @@ -0,0 +1,523 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// VTank's tell-driven fellowship manager: waiting-list recruitment, status +/// commands, and two-minute member votes. The host owns only the retail wire +/// commands; every queue and vote remains plugin policy. +/// +internal sealed class FellowshipManager +{ + private const int MaximumOtherMembers = 8; + private const double RequestLifetimeSeconds = 300d; + private const double VoteLifetimeSeconds = 120d; + private const double VoteCallerCooldownSeconds = 240d; + private const double RecruitRangeMeters = 10d; + + private readonly IPluginHost _host; + private readonly List _waiting = []; + private readonly HashSet _banned = + new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _voteCooldowns = + new(StringComparer.OrdinalIgnoreCase); + private readonly List _votes = []; + private readonly Dictionary> _tellRate = + new(StringComparer.OrdinalIgnoreCase); + private ulong _chatSequence; + private double _now; + private double _nextRecruitAt; + private int _nextVoteId = 1; + private bool _wasLeader; + private bool _desiredOpen = true; + + public FellowshipManager(IPluginHost host) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + } + + public string Status { get; private set; } = "Fellow manager idle"; + public IReadOnlyList WaitingNames => + _waiting.Select(static value => value.Name).ToArray(); + + public void Tick(double elapsedSeconds, bool enabled) + { + _now += Math.Max(0d, elapsedSeconds); + IReadOnlyList messages = + _host.Automation.Chat.CaptureMessages(_chatSequence); + foreach (PluginChatMessage message in messages) + { + _chatSequence = Math.Max(_chatSequence, message.Sequence); + if (enabled && IsIncomingTell(message)) + HandleTell(message); + } + + IFellowshipAutomation fellowship = _host.Automation.Fellowship; + if (!enabled || !fellowship.IsInFellowship) + { + Status = enabled ? "Not in a fellowship" : "Fellow manager disabled"; + if (!fellowship.IsInFellowship) + ResetSocialState(); + return; + } + + bool isLeader = fellowship.LeaderObjectId == _host.Automation.Character.ObjectId; + if (_wasLeader && !isLeader) + { + if (_votes.Count != 0) + Fellow("[VT Fellow Manager] I am no longer the fellowship leader. All votes have been canceled. -v-"); + _votes.Clear(); + _waiting.Clear(); + _banned.Clear(); + } + _wasLeader = isLeader; + + RemoveJoinedPlayers(fellowship.CaptureRoster()); + ExpireVotes(isLeader); + ExpireWaitingPlayers(); + if (isLeader) + RecruitNext(fellowship); + Status = isLeader + ? $"Fellow leader — {_waiting.Count} waiting, {_votes.Count} vote(s)" + : $"Fellow member — leader {LeaderName(fellowship)}"; + } + + public void Reset() + { + _chatSequence = 0u; + _now = 0d; + _nextRecruitAt = 0d; + _nextVoteId = 1; + _wasLeader = false; + ResetSocialState(); + _tellRate.Clear(); + Status = "Fellow manager idle"; + } + + private void HandleTell(PluginChatMessage message) + { + string sender = message.Sender.Trim(); + string command = message.Text.Trim(); + if (sender.Length == 0 || command.Length == 0 || IsSpam(sender)) + return; + + IFellowshipAutomation fellowship = _host.Automation.Fellowship; + IReadOnlyList roster = fellowship.CaptureRoster(); + bool isMember = roster.Any(member => member.Name.Equals( + sender, StringComparison.OrdinalIgnoreCase)); + bool isLeader = fellowship.LeaderObjectId == _host.Automation.Character.ObjectId; + + if (command.Equals("xp", StringComparison.OrdinalIgnoreCase)) + { + RequestRecruit(sender, message.SenderObjectId, roster, isLeader); + return; + } + if (command.Equals("line", StringComparison.OrdinalIgnoreCase) + || command.Equals("list", StringComparison.OrdinalIgnoreCase) + || command.Equals("status", StringComparison.OrdinalIgnoreCase)) + { + SendLineStatus(sender, fellowship, isLeader); + return; + } + if (command.Equals("remove", StringComparison.OrdinalIgnoreCase)) + { + RemoveWaiting(sender); + Tell(sender, "[VT Fellow Manager] You have been removed from the list. -v-"); + return; + } + if (command.Equals("leader", StringComparison.OrdinalIgnoreCase)) + { + string openness = fellowship.IsOpen ? "open" : "closed"; + Tell(sender, isLeader + ? $"[VT Fellow Manager] I am the fellowship leader. The fellowship is {openness}. -v-" + : $"[VT Fellow Manager] The leader is currently: {LeaderName(fellowship)}. The fellowship is {openness}. -v-"); + return; + } + if (command.Equals("help", StringComparison.OrdinalIgnoreCase)) + { + Tell(sender, "[VT Fellow Manager] Available commands: xp, line, remove, leader, startvote, vote, location, help -v-"); + return; + } + if (command.Equals("help startvote", StringComparison.OrdinalIgnoreCase)) + { + Tell(sender, "[VT Fellow Manager] Usage: startvote [votetype] [parameter]. Possible vote types: kick, ban, giveleader, setopen. -v-"); + return; + } + if (command.Equals("help vote", StringComparison.OrdinalIgnoreCase)) + { + Tell(sender, "[VT Fellow Manager] Usage: vote [vote id] [yes/no] -v-"); + return; + } + if (command.StartsWith("startvote ", StringComparison.OrdinalIgnoreCase)) + { + StartVote(sender, command, roster, isMember, isLeader); + return; + } + if (command.StartsWith("vote ", StringComparison.OrdinalIgnoreCase)) + { + CastVote(sender, command, isMember); + return; + } + if (command.Equals("location", StringComparison.OrdinalIgnoreCase)) + { + Tell(sender, isMember + ? $"[VT Fellow Manager] I am currently located in landcell: {_host.Automation.Navigation.Snapshot.Position.CellId:X8} -v-" + : "[VT Fellow Manager] Sorry, I can only send my location to members of the fellowship. -v-"); + } + } + + private void RequestRecruit( + string sender, + uint senderObjectId, + IReadOnlyList roster, + bool isLeader) + { + if (roster.Any(member => member.Name.Equals( + sender, StringComparison.OrdinalIgnoreCase))) + { + Tell(sender, "[VT Fellow Manager] You are already in the fellowship. -v-"); + return; + } + if (_banned.Contains(sender)) + { + Tell(sender, "[VT Fellow Manager] Sorry, but you have been banned from this fellowship. -v-"); + return; + } + IFellowshipAutomation fellowship = _host.Automation.Fellowship; + if (!isLeader && !fellowship.IsOpen) + { + Tell(sender, $"[VT Fellow Manager] I'm sorry, but the fellowship is closed and I am not the leader. The leader is currently: {LeaderName(fellowship)} -v-"); + return; + } + WaitingPlayer? existing = _waiting.FirstOrDefault(value => + value.Name.Equals(sender, StringComparison.OrdinalIgnoreCase)); + if (existing is not null) + { + existing.ObjectId = senderObjectId != 0u ? senderObjectId : existing.ObjectId; + existing.ExpiresAt = _now + RequestLifetimeSeconds; + int position = _waiting.IndexOf(existing) + 1; + Tell(sender, $"[VT Fellow Manager] You are already number {position} of {_waiting.Count} on the waiting list. -v-"); + return; + } + + _waiting.Add(new WaitingPlayer( + sender, + senderObjectId, + _now + RequestLifetimeSeconds)); + if (isLeader && roster.Count >= MaximumOtherMembers + 1) + { + _desiredOpen = fellowship.IsOpen; + fellowship.SetOpen(false); + Tell(sender, $"[VT Fellow Manager] The fellow is full, and I am the leader. I am adding you to the waiting list at position {_waiting.Count} -v-"); + } + else + { + Tell(sender, "[VT Fellow Manager] I will recruit you in a moment. Please stand close to me. -v-"); + } + } + + private void RecruitNext(IFellowshipAutomation fellowship) + { + if (_waiting.Count == 0) + { + if (fellowship.IsOpen != _desiredOpen) + fellowship.SetOpen(_desiredOpen); + return; + } + if (fellowship.CaptureRoster().Count >= MaximumOtherMembers + 1) + { + if (fellowship.IsOpen) + fellowship.SetOpen(false); + return; + } + if (_now < _nextRecruitAt) + return; + WaitingPlayer player = _waiting[0]; + if (player.ObjectId == 0u || !IsNear(player.ObjectId)) + { + player.Attempts++; + _nextRecruitAt = _now + 1d; + if (player.Attempts == 16) + Tell(player.Name, "[VT Fellow Manager] You are too far away. I will wait 20 seconds and give you one more chance. -v-"); + if (player.Attempts > 30) + { + Tell(player.Name, "[VT Fellow Manager] I'm sorry, but I couldn't recruit you. Please try again. -v-"); + _waiting.RemoveAt(0); + } + return; + } + PluginFellowshipCommandResult result = fellowship.Recruit(player.ObjectId); + _nextRecruitAt = _now + 1d; + if (!result.Accepted) + player.Attempts++; + } + + private void StartVote( + string sender, + string command, + IReadOnlyList roster, + bool isMember, + bool isLeader) + { + if (!isMember || _banned.Contains(sender)) + return; + if (!isLeader) + { + Tell(sender, "[VT Fellow Manager] I am not the fellowship leader and cannot manage votes. -v-"); + return; + } + if (_voteCooldowns.TryGetValue(sender, out double readyAt) && readyAt > _now) + { + Tell(sender, "[VT Fellow Manager] You have initiated a vote too recently. -v-"); + return; + } + string[] parts = command.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 3) + { + Tell(sender, "[VT Fellow Manager] Not enough parameters to startvote command. Tell me 'help startvote' for more information. -v-"); + return; + } + string kindText = parts[1].ToLowerInvariant(); + string parameter = parts[2].Trim(); + FellowVoteKind kind; + if (kindText is "kick" or "ban" or "giveleader") + { + if (!roster.Any(member => member.Name.Equals( + parameter, StringComparison.OrdinalIgnoreCase))) + { + Tell(sender, $"[VT Fellow Manager] Cannot vote to {kindText} {parameter}, that player is not in the fellow. -v-"); + return; + } + kind = kindText switch + { + "kick" => FellowVoteKind.Kick, + "ban" => FellowVoteKind.Ban, + _ => FellowVoteKind.GiveLeader, + }; + } + else if (kindText == "setopen" + && bool.TryParse(parameter, out _)) + { + kind = FellowVoteKind.SetOpen; + parameter = parameter.ToLowerInvariant(); + } + else + { + Tell(sender, "[VT Fellow Manager] Unknown vote type. Tell me 'help startvote' for more information. -v-"); + return; + } + if (_votes.Any(value => value.Kind == kind + && value.Parameter.Equals(parameter, StringComparison.OrdinalIgnoreCase))) + { + Tell(sender, "[VT Fellow Manager] An identical vote is already in progress! -v-"); + return; + } + var vote = new FellowVote( + _nextVoteId++, kind, parameter, _now + VoteLifetimeSeconds); + vote.Ballots[sender] = true; + _votes.Add(vote); + _voteCooldowns[sender] = _now + VoteCallerCooldownSeconds; + Fellow($"[VT Fellow Manager] {sender} has called a new vote: {kindText} {parameter}! To vote, tell me 'vote {vote.Id} yes' or 'vote {vote.Id} no'. You have 2 minutes. -v-"); + AnnounceVote(vote); + } + + private void CastVote(string sender, string command, bool isMember) + { + if (!isMember || _banned.Contains(sender)) + return; + string[] parts = command.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 3 + || !int.TryParse(parts[1], out int id) + || !(parts[2].Equals("yes", StringComparison.OrdinalIgnoreCase) + || parts[2].Equals("no", StringComparison.OrdinalIgnoreCase))) + { + Tell(sender, "[VT Fellow Manager] Invalid vote command. Votes should look like: vote idnumber yes, or: vote idnumber no -v-"); + return; + } + FellowVote? vote = _votes.FirstOrDefault(value => value.Id == id); + if (vote is null) + { + Tell(sender, "[VT Fellow Manager] Invalid vote ID number. Votes should look like: vote idnumber yes, or: vote idnumber no -v-"); + return; + } + vote.Ballots[sender] = parts[2].Equals("yes", StringComparison.OrdinalIgnoreCase); + AnnounceVote(vote); + } + + private void ExpireVotes(bool isLeader) + { + foreach (FellowVote vote in _votes.Where(value => value.ExpiresAt <= _now).ToArray()) + { + _votes.Remove(vote); + int yes = vote.Ballots.Values.Count(static value => value); + int no = vote.Ballots.Count - yes; + bool passed = yes > (yes + no) / 2; + Fellow($"[VT Fellow Manager] Vote {vote.Description} {(passed ? "passed" : "failed")} ({yes}/{no}). -v-"); + if (passed && isLeader) + ExecuteVote(vote); + } + } + + private void ExecuteVote(FellowVote vote) + { + IFellowshipAutomation fellowship = _host.Automation.Fellowship; + PluginFellowMember target = fellowship.CaptureRoster().FirstOrDefault(member => + member.Name.Equals(vote.Parameter, StringComparison.OrdinalIgnoreCase)); + switch (vote.Kind) + { + case FellowVoteKind.Kick when target.ObjectId != 0u: + fellowship.Dismiss(target.ObjectId); + break; + case FellowVoteKind.Ban when target.ObjectId != 0u: + _banned.Add(target.Name); + fellowship.Dismiss(target.ObjectId); + break; + case FellowVoteKind.GiveLeader when target.ObjectId != 0u: + fellowship.AssignLeader(target.ObjectId); + break; + case FellowVoteKind.SetOpen: + _desiredOpen = bool.Parse(vote.Parameter); + fellowship.SetOpen(_desiredOpen); + break; + } + } + + private void SendLineStatus( + string sender, + IFellowshipAutomation fellowship, + bool isLeader) + { + if (!isLeader) + { + Tell(sender, $"[VT Fellow Manager] The leader is currently: {LeaderName(fellowship)}. The fellowship is {(fellowship.IsOpen ? "open" : "closed")}. -v-"); + return; + } + WaitingPlayer? waiting = _waiting.FirstOrDefault(value => + value.Name.Equals(sender, StringComparison.OrdinalIgnoreCase)); + if (waiting is null) + { + Tell(sender, _waiting.Count == 0 + ? $"[VT Fellow Manager] There is no waiting list. The fellowship has {fellowship.CaptureRoster().Count} members. -v-" + : $"[VT Fellow Manager] The waiting list contains {_waiting.Count} players. You are not on it. -v-"); + return; + } + Tell(sender, $"[VT Fellow Manager] You are number {_waiting.IndexOf(waiting) + 1} of {_waiting.Count} on the waiting list. -v-"); + } + + private void RemoveJoinedPlayers(IReadOnlyList roster) + { + _waiting.RemoveAll(waiting => roster.Any(member => member.Name.Equals( + waiting.Name, StringComparison.OrdinalIgnoreCase))); + foreach (FellowVote vote in _votes) + { + foreach (string voter in vote.Ballots.Keys + .Where(name => !roster.Any(member => member.Name.Equals( + name, StringComparison.OrdinalIgnoreCase))) + .ToArray()) + { + vote.Ballots.Remove(voter); + } + } + } + + private void ExpireWaitingPlayers() + { + foreach (WaitingPlayer player in _waiting + .Where(value => value.ExpiresAt <= _now).ToArray()) + { + _waiting.Remove(player); + Tell(player.Name, "[VT Fellow Manager] Your spot in the fellowship has expired. You have been removed from the list. -v-"); + } + } + + private bool IsNear(uint objectId) + { + INavigationAutomation navigation = _host.Automation.Navigation; + PluginNavigationSnapshot self = navigation.Snapshot; + return self.IsAvailable + && navigation.TryGetObject(objectId, out PluginNavigationObject target) + && self.Position.HorizontalDistanceMeters(target.Position) + <= RecruitRangeMeters; + } + + private bool IsSpam(string sender) + { + if (!_tellRate.TryGetValue(sender, out Queue? times)) + { + times = new Queue(); + _tellRate[sender] = times; + } + while (times.Count != 0 && times.Peek() <= _now - 180d) + times.Dequeue(); + times.Enqueue(_now); + return times.Count > 8; + } + + private static bool IsIncomingTell(in PluginChatMessage message) => + message.Kind == 3 && message.SenderObjectId != 0u; + + private string LeaderName(IFellowshipAutomation fellowship) => + fellowship.CaptureRoster().FirstOrDefault(member => + member.ObjectId == fellowship.LeaderObjectId).Name is { Length: > 0 } name + ? name + : "????"; + + private void RemoveWaiting(string name) => _waiting.RemoveAll(value => + value.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + + private void AnnounceVote(FellowVote vote) + { + int yes = vote.Ballots.Values.Count(static value => value); + int no = vote.Ballots.Count - yes; + Fellow($"[VT Fellow Manager] Vote total for {vote.Description}: {yes}/{no} -v-"); + } + + private void Tell(string player, string text) => + _host.Automation.Chat.Submit($"/t {player}, {text}"); + + private void Fellow(string text) => + _host.Automation.Chat.Submit("/f " + text); + + private void ResetSocialState() + { + _waiting.Clear(); + _banned.Clear(); + _voteCooldowns.Clear(); + _votes.Clear(); + _wasLeader = false; + } + + private sealed class WaitingPlayer( + string name, + uint objectId, + double expiresAt) + { + public string Name { get; } = name; + public uint ObjectId { get; set; } = objectId; + public double ExpiresAt { get; set; } = expiresAt; + public int Attempts { get; set; } + } + + private enum FellowVoteKind + { + Kick, + Ban, + GiveLeader, + SetOpen, + } + + private sealed class FellowVote( + int id, + FellowVoteKind kind, + string parameter, + double expiresAt) + { + public int Id { get; } = id; + public FellowVoteKind Kind { get; } = kind; + public string Parameter { get; } = parameter; + public double ExpiresAt { get; } = expiresAt; + public Dictionary Ballots { get; } = + new(StringComparer.OrdinalIgnoreCase); + public string Description => $"'{Kind} {Parameter}' (ID {Id})"; + } +} diff --git a/src/AcDream.Plugins.MossTank/GrenadeCatalog.cs b/src/AcDream.Plugins.MossTank/GrenadeCatalog.cs new file mode 100644 index 00000000..6ab7c68e --- /dev/null +++ b/src/AcDream.Plugins.MossTank/GrenadeCatalog.cs @@ -0,0 +1,79 @@ +namespace AcDream.Plugins.MossTank; + +internal readonly record struct GrenadeDefinition( + string Name, + uint SpellId, + int Spellcraft, + int RequiredAlchemy); + +/// +/// VTank's exact 72-entry GameInfoDB GrenadeOptions table. The source is the +/// official Virindi update feed (DB version 9), not an inferred name pattern. +/// +internal static class GrenadeCatalog +{ + private readonly record struct Tier( + string Name, + int RequiredAlchemy, + int Spellcraft, + uint Imperil, + uint Blade, + uint Acid, + uint Cold, + uint Bludgeon, + uint Fire, + uint Piercing, + uint Lightning, + uint Fester); + + private static readonly Tier[] Tiers = + [ + new("Iron", 75, 100, 1323, 1128, 522, 1061, 1049, 1104, 1152, 1085, 172), + new("Copper", 125, 160, 1324, 1129, 523, 1062, 1050, 1105, 1153, 1086, 173), + new("Silver", 175, 220, 1325, 1130, 524, 1063, 1051, 1106, 1154, 1087, 174), + new("Gold", 225, 270, 1326, 1131, 525, 1064, 1052, 1107, 1155, 1088, 175), + new("Pyreal", 275, 340, 1327, 1132, 526, 1065, 1053, 1108, 1156, 1089, 176), + new("Platinum", 325, 400, 1327, 1132, 526, 1065, 1053, 1108, 1156, 1089, 176), + new("Empowered Platinum", 375, 460, 1327, 1132, 526, 1065, 1053, 1108, 1156, 1089, 176), + new("Mana", 400, 520, 2074, 2164, 2162, 2168, 2166, 2170, 2174, 2172, 2178), + ]; + + private static readonly IReadOnlyList Entries = Build(); + private static readonly IReadOnlyDictionary ByName = + Entries.ToDictionary(entry => entry.Name, StringComparer.Ordinal); + + public static IReadOnlyList All => Entries; + + public static bool TryGet(string exactName, out GrenadeDefinition definition) => + ByName.TryGetValue(exactName, out definition); + + private static IReadOnlyList Build() + { + var result = new List(72); + foreach (Tier tier in Tiers) + { + Add(result, tier, "Imperil", tier.Imperil); + Add(result, tier, "Blade Vulnerability", tier.Blade); + Add(result, tier, "Acid Vulnerability", tier.Acid); + Add(result, tier, "Cold Vulnerability", tier.Cold); + Add(result, tier, "Bludgeon Vulnerability", tier.Bludgeon); + Add(result, tier, "Fire Vulnerability", tier.Fire); + Add(result, tier, "Piercing Vulnerability", tier.Piercing); + Add(result, tier, "Lightning Vulnerability", tier.Lightning); + } + foreach (Tier tier in Tiers) + Add(result, tier, "Fester", tier.Fester); + return result; + } + + private static void Add( + ICollection result, + Tier tier, + string effect, + uint spellId) => + result.Add(new GrenadeDefinition( + $"{tier.Name} Phial of {effect}", + spellId, + tier.Spellcraft, + tier.RequiredAlchemy)); +} diff --git a/src/AcDream.Plugins.MossTank/InventoryMaintenance.cs b/src/AcDream.Plugins.MossTank/InventoryMaintenance.cs new file mode 100644 index 00000000..249fe49c --- /dev/null +++ b/src/AcDream.Plugins.MossTank/InventoryMaintenance.cs @@ -0,0 +1,299 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal sealed class InventorySettings +{ + public bool ManaChargesWhenOff { get; set; } = true; + // Official VTank defaults from uTank2.Resources.defaultsettings.usd. + public bool AutoStack { get; set; } = true; + public bool AutoCram { get; set; } + public bool AutoCraftItems { get; set; } = true; + public int ArrowheadFletchDifficultyExcess { get; set; } = 10; + public bool SplitPeas { get; set; } = true; + public int CriticalComponentMinimum { get; set; } = 4; + public int NormalComponentMinimum { get; set; } = 20; + public int IdleComponentMinimum { get; set; } = 20; + public int IdleHealthKitCount { get; set; } = 2; + public int IdleStaminaKitCount { get; set; } = 2; + public int IdleManaKitCount { get; set; } = 2; + public int IdleHealthFoodCount { get; set; } = 15; + public int IdleStaminaFoodCount { get; set; } = 15; + public int IdleManaFoodCount { get; set; } = 15; + public bool RefillWornMana { get; set; } = true; + public int RefillWornManaPercent { get; set; } = 33; + public double ScanIntervalSeconds { get; set; } = 0.25d; + public LootSettings Loot { get; } = new(); +} + +internal enum InventoryMaintenanceKind +{ + Merge, + Cram, +} + +internal readonly record struct InventoryMaintenancePlan( + InventoryMaintenanceKind Kind, + uint SourceObjectId, + uint TargetObjectId, + uint Amount); + +/// +/// Pure VTank StackCram planner. AutoStack always wins over AutoCram; it groups +/// by WCID, picks the lowest-burden source and a non-full target, then performs +/// exactly one retail move. AutoCram moves one direct-main-pack non-container +/// into the first side pack with room. +/// +internal static class InventoryMaintenancePlanner +{ + private const uint PublicWeenieFoci = 0x00800000u; + private static readonly ISet EmptyIgnored = new HashSet(); + + public static InventoryMaintenancePlan? Plan( + IReadOnlyList items, + uint playerObjectId, + InventorySettings settings, + ISet? ignored = null) + { + ArgumentNullException.ThrowIfNull(items); + ArgumentNullException.ThrowIfNull(settings); + ignored ??= EmptyIgnored; + + if (settings.AutoStack) + { + InventoryMaintenancePlan? stack = PlanStack(items, ignored); + if (stack is not null) + return stack; + } + + return settings.AutoCram + ? PlanCram(items, playerObjectId, ignored) + : null; + } + + private static InventoryMaintenancePlan? PlanStack( + IReadOnlyList items, + ISet ignored) + { + Dictionary byId = items.ToDictionary( + static item => item.ObjectId); + foreach (IGrouping group in items + .Where(item => item.ObjectId != 0u + && item.WeenieClassId != 0u + && item.MaximumStackSize > 1 + && item.StackSize > 0 + && !item.IsEquipped + && !ignored.Contains(item.ObjectId)) + .GroupBy(static item => item.WeenieClassId) + .OrderBy(static group => group.Key)) + { + PluginInventoryItem[] ordered = group + .OrderBy(item => BurdenRank(item, byId)) + .ThenBy(static item => item.ContainerSlot) + .ThenBy(static item => item.ObjectId) + .ToArray(); + if (ordered.Length < 2) + continue; + + PluginInventoryItem source = ordered[0]; + for (int i = ordered.Length - 1; i >= 1; i--) + { + PluginInventoryItem target = ordered[i]; + int free = target.MaximumStackSize - Math.Max(1, target.StackSize); + if (free <= 0) + continue; + uint amount = (uint)Math.Min(Math.Max(1, source.StackSize), free); + return new InventoryMaintenancePlan( + InventoryMaintenanceKind.Merge, + source.ObjectId, + target.ObjectId, + amount); + } + } + return null; + } + + private static InventoryMaintenancePlan? PlanCram( + IReadOnlyList items, + uint playerObjectId, + ISet ignored) + { + if (playerObjectId == 0u) + return null; + + PluginInventoryItem source = items + .Where(item => item.ContainerObjectId == playerObjectId + && item.WielderObjectId == 0u + && item.ItemsCapacity <= 0 + && item.ContainersCapacity <= 0 + && (item.PublicFlags & PublicWeenieFoci) == 0u + && !ignored.Contains(item.ObjectId)) + .OrderBy(static item => item.ContainerSlot) + .ThenBy(static item => item.ObjectId) + .FirstOrDefault(); + if (source.ObjectId == 0u) + return null; + + Dictionary containedCounts = items + .Where(static item => item.ContainerObjectId != 0u) + .GroupBy(static item => item.ContainerObjectId) + .ToDictionary(static group => group.Key, static group => group.Count()); + PluginInventoryItem destination = items + .Where(item => item.ContainerObjectId == playerObjectId + && item.ItemsCapacity > 0 + && !ignored.Contains(item.ObjectId) + && containedCounts.GetValueOrDefault(item.ObjectId) + < item.ItemsCapacity) + .OrderBy(static item => item.ContainerSlot) + .ThenBy(static item => item.ObjectId) + .FirstOrDefault(); + return destination.ObjectId != 0u + ? new InventoryMaintenancePlan( + InventoryMaintenanceKind.Cram, + source.ObjectId, + destination.ObjectId, + (uint)Math.Max(1, source.StackSize)) + : null; + } + + private static long BurdenRank( + PluginInventoryItem item, + IReadOnlyDictionary byId) + { + long parent = item.ContainerObjectId != 0u + && byId.TryGetValue(item.ContainerObjectId, out PluginInventoryItem container) + ? Math.Max(0, container.Burden) + 1L + : 0L; + return Math.Max(0, item.Burden) + (10_000L * parent); + } + +} + +/// +/// Executes one StackCram operation at a time and waits for the host's +/// authoritative inventory receipt before planning the next one. +/// +internal sealed class InventoryMaintenanceController +{ + private const int RetailAbandonAttempts = 80; + private readonly IPluginHost _host; + private readonly InventorySettings _settings; + private readonly Dictionary<(uint Source, uint Target), int> _attempts = []; + private readonly HashSet _ignored = []; + private InventoryMaintenancePlan? _pending; + private long _observedRevision; + private double _untilScan; + + public InventoryMaintenanceController( + IPluginHost host, + InventorySettings settings) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + } + + public string Status { get; private set; } = "Stack/Cram idle"; + + /// Returns true only when StackCram owns this scheduler tick. + public bool Tick(double elapsedSeconds, bool canAct) + { + IItemAutomation commands = _host.Automation.Items; + ObserveCompletion(commands); + if (_pending is not null) + { + if (commands.IsBusy) + return true; + // Older hosts may implement the command but not receipts. The + // canonical host always publishes one before clearing Busy. + _pending = null; + } + if (!canAct || !_host.Automation.IsAvailable || !commands.IsAvailable) + return false; + if (!_settings.AutoStack && !_settings.AutoCram) + { + Status = "Stack/Cram disabled"; + return false; + } + if (commands.IsBusy) + return false; + + _untilScan -= Math.Max(0d, elapsedSeconds); + if (_untilScan > 0d) + return false; + _untilScan = Math.Max(0.05d, _settings.ScanIntervalSeconds); + + IReadOnlyList inventory = commands.CaptureOwnedItems(); + _ignored.RemoveWhere(id => !inventory.Any(item => item.ObjectId == id)); + InventoryMaintenancePlan? plan = InventoryMaintenancePlanner.Plan( + inventory, + _host.Automation.Character.ObjectId, + _settings, + _ignored); + if (plan is not { } next) + { + Status = "Stack/Cram idle"; + return false; + } + + PluginItemCommandResult result = next.Kind == InventoryMaintenanceKind.Merge + ? commands.Merge(next.SourceObjectId, next.TargetObjectId, next.Amount) + : commands.MoveToContainer( + next.SourceObjectId, + next.TargetObjectId, + next.Amount); + if (!result.Accepted) + { + Status = $"Stack/Cram waiting: {result.Status}"; + return result.Status == PluginItemCommandStatus.Busy; + } + + _pending = next; + Status = next.Kind == InventoryMaintenanceKind.Merge + ? "Stacking items" + : "Moving an item to a side pack"; + return true; + } + + public void Reset() + { + _pending = null; + _attempts.Clear(); + _ignored.Clear(); + _untilScan = 0d; + Status = "Stack/Cram idle"; + } + + private void ObserveCompletion(IItemAutomation commands) + { + PluginInventoryCompletion completion = commands.LastInventoryCompletion; + if (completion.Revision == 0 || completion.Revision == _observedRevision) + return; + _observedRevision = completion.Revision; + if (_pending is not { } pending + || completion.SourceObjectId != pending.SourceObjectId) + { + return; + } + + if (!completion.IsSuccess) + { + var key = (pending.SourceObjectId, pending.TargetObjectId); + int attempts = _attempts.GetValueOrDefault(key) + 1; + _attempts[key] = attempts; + Status = $"Stack/Cram failed (0x{completion.WeenieError:X})"; + if (attempts > RetailAbandonAttempts) + { + _ignored.Add(pending.SourceObjectId); + _ignored.Add(pending.TargetObjectId); + _host.Automation.Chat.PostSystemMessage( + "[MossTank] Abandoned trying to stack/cram two bugged items."); + } + } + else + { + _attempts.Remove((pending.SourceObjectId, pending.TargetObjectId)); + } + _pending = null; + _untilScan = 0d; + } +} diff --git a/src/AcDream.Plugins.MossTank/ItemManaRecharge.cs b/src/AcDream.Plugins.MossTank/ItemManaRecharge.cs new file mode 100644 index 00000000..af51003f --- /dev/null +++ b/src/AcDream.Plugins.MossTank/ItemManaRecharge.cs @@ -0,0 +1,140 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal readonly record struct ItemManaRechargePlan( + uint ChargeObjectId, + uint TargetObjectId, + string ChargeName, + string TargetName, + int CurrentMana, + int MaximumMana); + +internal static class ItemManaRechargePlanner +{ + private const uint ManaStoneItemType = 0x00080000u; + + public static ItemManaRechargePlan? Plan( + IReadOnlyList inventory, + ISet consumableNames, + int thresholdPercent) + { + ArgumentNullException.ThrowIfNull(inventory); + ArgumentNullException.ThrowIfNull(consumableNames); + int threshold = Math.Clamp(thresholdPercent, 0, 99); + PluginInventoryItem charge = inventory + .Where(item => (item.ItemType & ManaStoneItemType) != 0u + && consumableNames.Contains(item.Name) + && !item.IsEquipped) + .Where(static item => item.ItemCurrentMana > 0) + .OrderBy(static item => item.Name, StringComparer.Ordinal) + .ThenBy(static item => item.ObjectId) + .FirstOrDefault(); + if (charge.ObjectId == 0u) + return null; + + PluginInventoryItem target = inventory + .Where(item => item.IsEquipped + && item.ItemMaximumMana > 0 + && 100L * Math.Max(0, item.ItemCurrentMana) + / item.ItemMaximumMana < threshold) + .OrderBy(item => 100d * Math.Max(0, item.ItemCurrentMana) + / item.ItemMaximumMana) + .ThenBy(static item => item.ObjectId) + .FirstOrDefault(); + return target.ObjectId == 0u + ? null + : new ItemManaRechargePlan( + charge.ObjectId, + target.ObjectId, + charge.Name, + target.Name, + target.ItemCurrentMana, + target.ItemMaximumMana); + } +} + +internal sealed class ItemManaRechargeController +{ + private readonly IPluginHost _host; + private readonly InventorySettings _settings; + private readonly CombatSettings _profiles; + private ItemManaRechargePlan? _pending; + private long _observedCompletion; + + public ItemManaRechargeController( + IPluginHost host, + InventorySettings settings, + CombatSettings profiles) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _profiles = profiles ?? throw new ArgumentNullException(nameof(profiles)); + } + + public string Status { get; private set; } = "Worn mana ready"; + + public bool Tick(bool canAct) + { + IItemAutomation items = _host.Automation.Items; + ObserveCompletion(items); + if (_pending is not null) + { + if (items.IsBusy) + return true; + _pending = null; + } + if (!canAct + || !_settings.RefillWornMana + || !_host.Automation.IsAvailable + || !items.IsAvailable + || items.IsBusy) + { + return false; + } + + ItemManaRechargePlan? plan = ItemManaRechargePlanner.Plan( + items.CaptureOwnedItems(), + _profiles.ConsumableNames, + _settings.RefillWornManaPercent); + if (plan is not { } next) + { + Status = "Worn mana ready"; + return false; + } + PluginItemCommandResult result = items.Apply( + next.ChargeObjectId, + next.TargetObjectId); + if (!result.Accepted) + { + Status = $"Mana refill waiting: {result.Status}"; + return result.Status == PluginItemCommandStatus.Busy; + } + _pending = next; + Status = $"Refilling {next.TargetName} ({next.CurrentMana}/{next.MaximumMana})"; + return true; + } + + public void Reset() + { + _pending = null; + Status = "Worn mana ready"; + } + + private void ObserveCompletion(IItemAutomation items) + { + PluginItemUseCompletion completion = items.LastCompletion; + if (completion.Revision == 0 || completion.Revision == _observedCompletion) + return; + _observedCompletion = completion.Revision; + if (_pending is not { } pending + || completion.SourceObjectId != pending.ChargeObjectId) + { + return; + } + Status = completion.IsSuccess + ? $"Refilled {pending.TargetName}" + : $"Mana refill failed (0x{completion.WeenieError:X})"; + _pending = null; + } +} diff --git a/src/AcDream.Plugins.MossTank/Looting.cs b/src/AcDream.Plugins.MossTank/Looting.cs new file mode 100644 index 00000000..5474be54 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Looting.cs @@ -0,0 +1,1766 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal enum LootAction +{ + NoLoot, + Keep, + Salvage, + Sell, + Read, + User1, + User2, + User3, + User4, + User5, + KeepUpTo, + ManaStone, + ManaTank, +} + +internal sealed class LootRule +{ + private string _expression = "*"; + private string? _compiledSource; + private LootRuleExpression? _compiled; + + public string Name { get; set; } = "Rule"; + public string Expression + { + get => _expression; + set => _expression = string.IsNullOrWhiteSpace(value) ? "*" : value.Trim(); + } + public LootAction Action { get; set; } = LootAction.Keep; + public int KeepCount { get; set; } = 1; + public int Priority { get; set; } + public string CustomExpression { get; set; } = string.Empty; + public List VtankRequirements { get; set; } = []; + + public bool IsMatch( + in PluginInventoryItem item, + in PluginItemProperties properties, + IPluginHost? host, + out string? error) + { + if (VtankRequirements.Count > 0) + { + return VtankLootRequirementEvaluator.IsMatch( + VtankRequirements, + item, + properties, + host, + out error); + } + try + { + if (_compiled is null + || !string.Equals( + _compiledSource, + Expression, + StringComparison.Ordinal)) + { + _compiled = LootRuleExpression.Compile(Expression); + _compiledSource = Expression; + } + error = null; + return _compiled.IsMatch(item, properties); + } + catch (FormatException failure) + { + error = failure.Message; + return false; + } + } +} + +internal sealed class LootSettings +{ + // Official VTank defaults from uTank2.Resources.defaultsettings.usd. + public bool Enabled { get; set; } + public string ExternalClassifierId { get; set; } = string.Empty; + public bool PriorityBoost { get; set; } + public bool LootAllCorpses { get; set; } + public bool LootFellowCorpses { get; set; } + public bool LootOnlyRareCorpses { get; set; } + public bool ReadUnknownScrolls { get; set; } = true; + public bool CombineSalvage { get; set; } = true; + public int ManaStoneLootCount { get; set; } = 4; + public int ManaTankMinimumMana { get; set; } = 1000; + public float CorpseApproachRange { get; set; } = 40f; + public float CorpseMinimumApproachRange { get; set; } = 3.36f; + public double CorpseOpenTimeoutSeconds { get; set; } = 1.5d; + public double CorpseItemAppearanceTimeoutSeconds { get; set; } = 6d; + public double CorpseItemIdentifyTimeoutSeconds { get; set; } = 60d; + public int BlacklistCorpseOpenAttemptCount { get; set; } = 30; + public double BlacklistCorpseOpenTimeoutSeconds { get; set; } = 200d; + public double CorpseCacheTimeoutMinutes { get; set; } = 60d; + public int CorpseLootItemMaxAttempts { get; set; } = 20; + public double ScanIntervalSeconds { get; set; } = 0.25d; + public List Rules { get; } = []; + public VtankSalvageCombineSettings SalvageCombine { get; set; } = new(); +} + +internal readonly record struct LootDecision( + LootAction Action, + int Priority, + int RuleIndex, + string RuleName, + string ClassifierId = ""); + +internal readonly record struct ManaStoneTransferPlan( + uint StoneObjectId, + uint TankObjectId, + string StoneName, + string TankName); + +internal static class ManaStoneTransferPlanner +{ + private const uint ManaStoneItemType = 0x00080000u; + private const uint RetainedFlag = 0x01000000u; + + public static ManaStoneTransferPlan? Plan( + IReadOnlyList owned, + IReadOnlyDictionary classified, + int minimumTankMana) + { + PluginInventoryItem stone = owned + .Where(item => classified.TryGetValue( + item.ObjectId, + out LootAction action) + && action == LootAction.ManaStone + && (item.ItemType & ManaStoneItemType) != 0u) + .OrderBy(static item => item.ObjectId) + .FirstOrDefault(); + if (stone.ObjectId == 0u) + return null; + int minimum = Math.Clamp(minimumTankMana, 1, int.MaxValue); + PluginInventoryItem tank = owned + .Where(item => classified.TryGetValue( + item.ObjectId, + out LootAction action) + && action == LootAction.ManaTank + && item.ItemCurrentMana >= minimum + && item.Value != 0 + && (item.PublicFlags & RetainedFlag) == 0u) + .OrderByDescending(static item => item.ItemCurrentMana) + .ThenBy(static item => item.ObjectId) + .FirstOrDefault(); + return tank.ObjectId == 0u + ? null + : new ManaStoneTransferPlan( + stone.ObjectId, + tank.ObjectId, + stone.Name, + tank.Name); + } +} + +internal sealed record SalvageBagCombinePlan( + IReadOnlyList ObjectIds, + uint MaterialType, + IReadOnlyList Names) +{ + public uint FirstObjectId => ObjectIds.Count > 0 ? ObjectIds[0] : 0u; + public uint SecondObjectId => ObjectIds.Count > 1 ? ObjectIds[1] : 0u; + public string FirstName => Names.Count > 0 ? Names[0] : string.Empty; + public string SecondName => Names.Count > 1 ? Names[1] : string.Empty; +} + +internal static partial class SalvageBagCombinePlanner +{ + public static SalvageBagCombinePlan? Plan( + IReadOnlyList owned, + ISet? abandoned = null, + VtankSalvageCombineSettings? settings = null) + { + settings ??= new VtankSalvageCombineSettings(); + PluginInventoryItem[] bags = owned + .Where(item => item.MaterialType != 0u + && SalvageBagName().IsMatch(item.Name) + && (abandoned is null || !abandoned.Contains(item.ObjectId))) + .OrderBy(static item => item.MaterialType) + .ThenBy(static item => item.Workmanship) + .ThenBy(static item => item.ObjectId) + .ToArray(); + foreach (IGrouping materialGroup in + bags.GroupBy(static item => item.MaterialType)) + { + string combine = settings.MaterialCombineStrings.TryGetValue( + checked((int)materialGroup.Key), + out string? materialCombine) + ? materialCombine + : settings.DefaultCombineString; + IReadOnlyList<(double Minimum, double Maximum)> ranges = + ParseCombineString(combine); + foreach (IGrouping bin in materialGroup + .GroupBy(item => RangeIndex(ranges, item.Workmanship)) + .OrderBy(static group => group.Key)) + { + PluginInventoryItem[] candidates = bin.ToArray(); + if (candidates.Length < 2) + continue; + IReadOnlyList selected; + if (settings.MaterialValueModeValues.TryGetValue( + checked((int)materialGroup.Key), + out int targetValue)) + { + if (candidates.Sum(static item => item.Value) >= targetValue) + { + selected = candidates; + } + else + { + selected = FindSubHundredPair(candidates); + if (selected.Count == 0) + continue; + } + } + else + { + var maximumBags = new List(); + int units = 0; + foreach (PluginInventoryItem candidate in candidates) + { + maximumBags.Add(candidate); + units += Math.Max(0, candidate.Structure); + if (units >= 100) + break; + } + selected = maximumBags; + } + return new SalvageBagCombinePlan( + selected.Select(static item => item.ObjectId).ToArray(), + materialGroup.Key, + selected.Select(static item => item.Name).ToArray()); + } + } + return null; + } + + internal static bool SameVtankWorkmanshipBand(float left, float right) => + (left < 7f && right < 7f) + || (left >= 7f && left < 9f && right >= 7f && right < 9f) + || (left >= 9f && left < 10f && right >= 9f && right < 10f) + || (left == 10f && right == 10f); + + internal static bool SameCombineBand( + float left, + float right, + string combineString) + { + IReadOnlyList<(double Minimum, double Maximum)> ranges = + ParseCombineString(combineString); + return RangeIndex(ranges, left) == RangeIndex(ranges, right); + } + + private static IReadOnlyList FindSubHundredPair( + IReadOnlyList candidates) + { + for (int left = 0; left < candidates.Count - 1; left++) + { + for (int right = left + 1; right < candidates.Count; right++) + { + if (Math.Max(0, candidates[left].Structure) + + Math.Max(0, candidates[right].Structure) < 100) + { + return [candidates[left], candidates[right]]; + } + } + } + return []; + } + + private static IReadOnlyList<(double Minimum, double Maximum)> + ParseCombineString(string? source) + { + var result = new List<(double, double)>(); + foreach (string token in (source ?? string.Empty).Split( + [',', ';'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + string[] bounds = token.Split( + '-', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (bounds.Length == 0 + || !double.TryParse(bounds[0], NumberStyles.Float, + CultureInfo.InvariantCulture, out double minimum)) + { + continue; + } + double maximum = minimum; + if (bounds.Length > 1) + { + _ = double.TryParse(bounds[1], NumberStyles.Float, + CultureInfo.InvariantCulture, out maximum); + } + result.Add((minimum, maximum)); + } + return result; + } + + private static int RangeIndex( + IReadOnlyList<(double Minimum, double Maximum)> ranges, + double value) + { + for (int index = 0; index < ranges.Count; index++) + { + if (ranges[index].Minimum > value) + return index - 1; + if (ranges[index].Minimum <= value + && ranges[index].Maximum >= value) + { + return index; + } + } + return ranges.Count; + } + + [GeneratedRegex(@"^Salvage(?:d)?.* \([0-9]{1,2}\)$", RegexOptions.IgnoreCase)] + private static partial Regex SalvageBagName(); +} + +internal static class LootRuleEngine +{ + public static LootDecision? Decide( + in PluginInventoryItem item, + in PluginItemProperties properties, + IReadOnlyList rules, + IReadOnlyList ownedItems, + IReadOnlyDictionary? pendingByName = null, + IPluginHost? host = null) + { + ArgumentNullException.ThrowIfNull(rules); + ArgumentNullException.ThrowIfNull(ownedItems); + + for (int index = 0; index < rules.Count; index++) + { + LootRule rule = rules[index]; + if (!rule.IsMatch(item, properties, host, out _)) + continue; + if (rule.Action == LootAction.NoLoot) + return null; + if (rule.Action == LootAction.KeepUpTo) + { + int limit = Math.Max(0, rule.KeepCount); + string itemName = item.Name; + int held = ownedItems + .Where(owned => string.Equals( + owned.Name, + itemName, + StringComparison.OrdinalIgnoreCase)) + .Sum(static owned => Math.Max(1, owned.StackSize)); + if (pendingByName is not null + && pendingByName.TryGetValue(item.Name, out int pending)) + { + held += pending; + } + if (held >= limit) + return null; + } + return new LootDecision( + rule.Action, + rule.Priority, + index, + string.IsNullOrWhiteSpace(rule.Name) + ? $"Rule {index + 1}" + : rule.Name); + } + return null; + } +} + +/// +/// VTank corpse-open / classify / pickup state machine. It owns policy only; +/// every action travels through the host's canonical retail item transaction. +/// +internal sealed class LootController +{ + private const double PickupTimeoutSeconds = 4d; + + private readonly IPluginHost _host; + private readonly LootSettings _settings; + private readonly Dictionary _completedCorpses = []; + private readonly Dictionary _corpseOpenAttempts = []; + private readonly Dictionary _corpseBlacklistedAt = []; + private readonly Dictionary _itemAttempts = []; + private readonly Dictionary _pendingByName = + new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _classifiedOwnedItems = []; + private readonly Dictionary _externalClassifierByItem = []; + private readonly Dictionary _decisions = []; + private readonly Dictionary _corpseFirstSeen = []; + private double _scanRemaining; + private double _stateAge; + private uint _activeCorpse; + private bool _activeCorpseSawContents; + private uint _waitingItem; + private string _waitingName = string.Empty; + private LootAction _waitingAction; + private int _waitingQuantity; + private PluginInventoryItem _waitingItemSnapshot; + private string _waitingClassifierId = string.Empty; + private long _waitingInventoryRevision; + private uint _awaitingAppraisal; + private uint _awaitingCorpseAppraisal; + private double _lifetime; + private uint _postUseItem; + private string _postUseName = string.Empty; + private bool _postUseStarted; + private long _postUseRevision; + private uint _salvagePendingItem; + private string _salvagePendingName = string.Empty; + private int _salvageAttempts; + private uint _sellPendingItem; + private string _sellPendingName = string.Empty; + private ManaStoneTransferPlan? _manaTransfer; + private long _manaTransferRevision; + private SalvageBagCombinePlan? _combinePending; + private readonly Dictionary _combineAttempts = []; + private readonly HashSet _abandonedCombineBags = []; + + public LootController( + IPluginHost host, + LootSettings settings) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + } + + public string Status { get; private set; } = "Looting disabled."; + public IReadOnlyDictionary ClassifiedOwnedItems => + _classifiedOwnedItems; + + public bool Tick(double elapsedSeconds, bool canAct) + { + ILootAutomation loot = _host.Automation.Loot; + if (!_settings.Enabled) + { + ResetTransient(); + Status = "Looting disabled."; + return false; + } + if (!_host.Automation.IsAvailable || !loot.IsAvailable) + { + Reset(); + Status = "Looting unavailable."; + return false; + } + if (_settings.Rules.Count == 0 + && string.IsNullOrWhiteSpace(_settings.ExternalClassifierId)) + { + ResetTransient(); + Status = "Loot profile has no rules."; + return false; + } + + if (_activeCorpse == 0u && _waitingItem == 0u) + PruneRemovedExternalItems(); + + _stateAge += Math.Max(0d, elapsedSeconds); + _lifetime += Math.Max(0d, elapsedSeconds); + if (_waitingItem != 0u) + return ContinuePickup(loot); + if (_postUseItem != 0u) + return ContinuePostUse(canAct); + if (_activeCorpse == 0u + && (_manaTransfer is not null + || HasManaStoneTransfer()) + && ContinueManaStoneTransfer(canAct)) + { + return true; + } + if (_activeCorpse == 0u + && (_salvagePendingItem != 0u + || _classifiedOwnedItems.Values.Contains(LootAction.Salvage)) + && ContinueSalvage(canAct)) + { + return true; + } + if (_activeCorpse == 0u + && _settings.CombineSalvage + && (_combinePending is not null || HasSalvageBagCombine()) + && ContinueSalvageBagCombine(canAct)) + { + return true; + } + if (_activeCorpse == 0u + && (_sellPendingItem != 0u + || _classifiedOwnedItems.Values.Contains(LootAction.Sell)) + && ContinueSell(canAct)) + { + return true; + } + + uint current = loot.CurrentContainerId; + if (_activeCorpse != 0u && current == _activeCorpse) + return ContinueCurrentCorpse(loot, canAct); + + if (_activeCorpse != 0u + && loot.RequestedContainerId == _activeCorpse) + { + if (_stateAge <= Math.Max(0.25d, _settings.CorpseOpenTimeoutSeconds)) + { + Status = "Waiting for corpse contents…"; + return true; + } + uint failedCorpse = _activeCorpse; + BlacklistFailedCorpse(failedCorpse); + _activeCorpse = 0u; + _activeCorpseSawContents = false; + _stateAge = 0d; + if (IsCorpseBlacklisted(failedCorpse)) + return false; + } + + if (!canAct || loot.IsBusy) + return false; + + _scanRemaining -= Math.Max(0d, elapsedSeconds); + if (_scanRemaining > 0d) + return false; + _scanRemaining = Math.Clamp(_settings.ScanIntervalSeconds, 0.05d, 5d); + + IReadOnlyList corpses = loot.CaptureCorpses( + Math.Clamp(_settings.CorpseApproachRange, 2f, 100f)); + PruneCorpseCache(); + foreach (PluginLootContainer seen in corpses) + _corpseFirstSeen.TryAdd(seen.ObjectId, _lifetime); + + if (_awaitingCorpseAppraisal != 0u) + { + PluginAppraisalState appraisal = loot.Appraisal; + if (appraisal.CurrentObjectId == _awaitingCorpseAppraisal + && appraisal.AwaitingObjectId != _awaitingCorpseAppraisal) + { + _awaitingCorpseAppraisal = 0u; + _stateAge = 0d; + } + else if (_stateAge < Math.Max( + 1d, + _settings.CorpseOpenTimeoutSeconds * 2d)) + { + Status = "Identifying corpse…"; + return true; + } + else + { + MarkCorpseComplete(_awaitingCorpseAppraisal); + _awaitingCorpseAppraisal = 0u; + _stateAge = 0d; + } + } + + PluginLootContainer? next = null; + foreach (PluginLootContainer candidateCorpse in corpses + .Where(corpse => !_completedCorpses.ContainsKey(corpse.ObjectId)) + .Where(corpse => !IsCorpseBlacklisted(corpse.ObjectId)) + .OrderBy(static corpse => corpse.Distance) + .ThenBy(static corpse => corpse.ObjectId)) + { + if (!candidateCorpse.IsIdentified) + { + PluginItemCommandResult identify = loot.Identify( + candidateCorpse.ObjectId); + if (identify.Accepted) + { + _awaitingCorpseAppraisal = candidateCorpse.ObjectId; + _stateAge = 0d; + Status = $"Identifying {candidateCorpse.Name}…"; + return true; + } + if (identify.Status == PluginItemCommandStatus.Busy) + return true; + continue; + } + if (!CanLoot(candidateCorpse)) + continue; + next = candidateCorpse; + break; + } + if (next is not { } corpse) + { + Status = "No nearby corpses."; + return false; + } + + PluginItemCommandResult opened = loot.Open(corpse.ObjectId); + if (!opened.Accepted) + { + Status = opened.Status == PluginItemCommandStatus.Busy + ? "Waiting to open corpse…" + : $"Could not open {corpse.Name}."; + return opened.Status == PluginItemCommandStatus.Busy; + } + _activeCorpse = corpse.ObjectId; + _activeCorpseSawContents = false; + _stateAge = 0d; + Status = $"Opening {corpse.Name}…"; + return true; + } + + public void Reset() + { + foreach ((uint objectId, string classifierId) in + _externalClassifierByItem.ToArray()) + { + _host.LootClassifiers.TryNotifyItemRemoved(classifierId, objectId); + } + ResetTransient(); + _completedCorpses.Clear(); + _corpseOpenAttempts.Clear(); + _corpseBlacklistedAt.Clear(); + _itemAttempts.Clear(); + _pendingByName.Clear(); + _classifiedOwnedItems.Clear(); + _externalClassifierByItem.Clear(); + _decisions.Clear(); + _corpseFirstSeen.Clear(); + _scanRemaining = 0d; + _lifetime = 0d; + _postUseItem = 0u; + _postUseName = string.Empty; + _postUseStarted = false; + _postUseRevision = 0L; + _salvagePendingItem = 0u; + _salvagePendingName = string.Empty; + _salvageAttempts = 0; + _sellPendingItem = 0u; + _sellPendingName = string.Empty; + _manaTransfer = null; + _manaTransferRevision = 0L; + _combinePending = null; + _combineAttempts.Clear(); + _abandonedCombineBags.Clear(); + Status = _settings.Enabled ? "Idle." : "Looting disabled."; + } + + private bool ContinueCurrentCorpse(ILootAutomation loot, bool canAct) + { + if (_stateAge < 0.10d) + { + Status = "Reading corpse contents…"; + return true; + } + + IReadOnlyList contents = + loot.CaptureCurrentContents(); + if (contents.Count > 0) + _activeCorpseSawContents = true; + if (contents.Count == 0 + && !_activeCorpseSawContents + && _stateAge < Math.Clamp( + _settings.CorpseItemAppearanceTimeoutSeconds, + 0d, + 300d)) + { + Status = "Waiting for corpse items to appear…"; + return true; + } + IReadOnlyList owned = + _host.Automation.Items.CaptureOwnedItems(); + if (_awaitingAppraisal != 0u) + { + PluginAppraisalState appraisal = loot.Appraisal; + if (appraisal.CurrentObjectId == _awaitingAppraisal + && appraisal.AwaitingObjectId != _awaitingAppraisal) + { + if (contents.FirstOrDefault( + item => item.ObjectId == _awaitingAppraisal) is { } item + && item.ObjectId != 0u) + { + PluginItemProperties identified = default; + _ = loot.TryCaptureProperties(item.ObjectId, out identified); + _decisions[item.ObjectId] = DecideItem( + item, + identified, + owned, + _pendingByName); + } + _awaitingAppraisal = 0u; + _stateAge = 0d; + } + else if (_stateAge < Math.Clamp( + _settings.CorpseItemIdentifyTimeoutSeconds, + 1d, + 600d)) + { + Status = "Identifying corpse item…"; + return true; + } + else + { + IncrementAttempt(_awaitingAppraisal); + _awaitingAppraisal = 0u; + _stateAge = 0d; + } + } + + foreach (PluginInventoryItem item in contents) + { + if (_decisions.ContainsKey(item.ObjectId)) + continue; + if (!canAct || loot.IsBusy) + return true; + + PluginAppraisalState appraisal = loot.Appraisal; + if (appraisal.CurrentObjectId != item.ObjectId) + { + PluginItemCommandResult identify = loot.Identify(item.ObjectId); + if (identify.Accepted) + { + _awaitingAppraisal = item.ObjectId; + _stateAge = 0d; + Status = $"Identifying {item.Name}…"; + return true; + } + if (identify.Status == PluginItemCommandStatus.Busy) + return true; + } + + PluginItemProperties properties = default; + _ = loot.TryCaptureProperties(item.ObjectId, out properties); + _decisions[item.ObjectId] = DecideItem( + item, + properties, + owned, + _pendingByName); + } + + var candidates = new List<(PluginInventoryItem Item, LootDecision Decision)>(); + foreach (PluginInventoryItem item in contents) + { + if (_itemAttempts.TryGetValue(item.ObjectId, out int attempts) + && attempts >= Math.Clamp( + _settings.CorpseLootItemMaxAttempts, + 1, + 100)) + { + continue; + } + if (_decisions.TryGetValue(item.ObjectId, out LootDecision? cached) + && cached is { } decision) + { + candidates.Add((item, decision)); + } + } + + if (candidates.Count == 0) + { + foreach (PluginInventoryItem item in contents) + _decisions.Remove(item.ObjectId); + MarkCorpseComplete(_activeCorpse); + _activeCorpse = 0u; + _activeCorpseSawContents = false; + _stateAge = 0d; + Status = "Corpse complete."; + return false; + } + if (!canAct || loot.IsBusy) + return true; + + (PluginInventoryItem Item, LootDecision Decision) chosen = candidates + .OrderByDescending(static candidate => candidate.Decision.Priority) + .ThenBy(static candidate => candidate.Decision.RuleIndex) + .ThenBy(static candidate => candidate.Item.ContainerSlot) + .ThenBy(static candidate => candidate.Item.ObjectId) + .First(); + PluginItemCommandResult pickup = loot.Pickup(chosen.Item.ObjectId); + if (!pickup.Accepted) + { + IncrementAttempt(chosen.Item.ObjectId); + Status = $"Pickup refused: {chosen.Item.Name}."; + return pickup.Status == PluginItemCommandStatus.Busy; + } + + _waitingItem = chosen.Item.ObjectId; + _waitingName = chosen.Item.Name; + _waitingAction = chosen.Decision.Action; + _waitingQuantity = Math.Max(1, chosen.Item.StackSize); + _waitingItemSnapshot = chosen.Item; + _waitingClassifierId = chosen.Decision.ClassifierId; + _waitingInventoryRevision = loot.LastInventoryCompletion.Revision; + _stateAge = 0d; + if (chosen.Decision.Action == LootAction.KeepUpTo) + { + _pendingByName.TryGetValue(chosen.Item.Name, out int pending); + _pendingByName[chosen.Item.Name] = + pending + _waitingQuantity; + } + Status = $"Looting {chosen.Item.Name} ({chosen.Decision.RuleName})…"; + return true; + } + + private bool ContinuePickup(ILootAutomation loot) + { + PluginInventoryCompletion completion = loot.LastInventoryCompletion; + bool advanced = completion.Revision > _waitingInventoryRevision + && completion.SourceObjectId == _waitingItem; + bool stillInCorpse = loot.CaptureCurrentContents().Any( + item => item.ObjectId == _waitingItem); + if (!advanced && stillInCorpse && _stateAge < PickupTimeoutSeconds) + { + Status = $"Waiting for {_waitingName}…"; + return true; + } + + bool success = !stillInCorpse || (advanced && completion.IsSuccess); + if (success) + { + _classifiedOwnedItems[_waitingItem] = _waitingAction; + if (_waitingClassifierId.Length != 0) + { + _externalClassifierByItem[_waitingItem] = _waitingClassifierId; + _host.LootClassifiers.TryNotifyLooted( + _waitingClassifierId, + new PluginLootedItem( + _waitingItemSnapshot, + (PluginLootAction)(int)_waitingAction)); + } + _decisions.Remove(_waitingItem); + Status = $"Looted {_waitingName}."; + _itemAttempts.Remove(_waitingItem); + if (_waitingAction == LootAction.Read) + { + _postUseItem = _waitingItem; + _postUseName = _waitingName; + _postUseStarted = false; + _postUseRevision = 0L; + } + } + else + { + IncrementAttempt(_waitingItem); + Status = $"Retrying {_waitingName}."; + } + if (_waitingAction == LootAction.KeepUpTo + && _pendingByName.TryGetValue(_waitingName, out int pending)) + { + if (pending <= _waitingQuantity) + _pendingByName.Remove(_waitingName); + else + _pendingByName[_waitingName] = pending - _waitingQuantity; + } + _waitingItem = 0u; + _waitingName = string.Empty; + _waitingAction = LootAction.NoLoot; + _waitingQuantity = 0; + _waitingItemSnapshot = default; + _waitingClassifierId = string.Empty; + _waitingInventoryRevision = 0L; + _stateAge = 0d; + return true; + } + + private bool ContinuePostUse(bool canAct) + { + IItemAutomation items = _host.Automation.Items; + if (_postUseStarted) + { + PluginItemUseCompletion completion = items.LastCompletion; + if (completion.Revision <= _postUseRevision + || completion.SourceObjectId != _postUseItem) + { + if (_stateAge < PickupTimeoutSeconds) + { + Status = $"Reading {_postUseName}…"; + return true; + } + Status = $"Read timed out: {_postUseName}."; + } + else + { + Status = completion.IsSuccess + ? $"Read {_postUseName}." + : $"Could not read {_postUseName}."; + } + _postUseItem = 0u; + _postUseName = string.Empty; + _postUseStarted = false; + _postUseRevision = 0L; + _stateAge = 0d; + return true; + } + if (!canAct || !items.IsAvailable || items.IsBusy) + return true; + PluginItemCommandResult use = items.Use(_postUseItem); + if (!use.Accepted) + { + if (use.Status == PluginItemCommandStatus.Busy) + return true; + Status = $"Could not read {_postUseName}."; + _postUseItem = 0u; + _postUseName = string.Empty; + return false; + } + _postUseRevision = items.LastCompletion.Revision; + _postUseStarted = true; + _stateAge = 0d; + Status = $"Reading {_postUseName}…"; + return true; + } + + private bool ContinueSalvage(bool canAct) + { + IItemAutomation items = _host.Automation.Items; + IReadOnlyList owned = items.CaptureOwnedItems(); + if (_salvagePendingItem != 0u) + { + if (!owned.Any(item => item.ObjectId == _salvagePendingItem)) + { + RemoveClassifiedOwned(_salvagePendingItem); + Status = $"Salvaged {_salvagePendingName}."; + _salvagePendingItem = 0u; + _salvagePendingName = string.Empty; + _salvageAttempts = 0; + _stateAge = 0d; + return true; + } + if (_stateAge < PickupTimeoutSeconds) + { + Status = $"Salvaging {_salvagePendingName}…"; + return true; + } + _salvagePendingItem = 0u; + _salvagePendingName = string.Empty; + _stateAge = 0d; + _salvageAttempts++; + } + + var ownedById = owned.ToDictionary(static item => item.ObjectId); + foreach (uint stale in _classifiedOwnedItems + .Where(entry => entry.Value == LootAction.Salvage + && !ownedById.ContainsKey(entry.Key)) + .Select(static entry => entry.Key) + .ToArray()) + { + RemoveClassifiedOwned(stale); + } + uint sourceId = _classifiedOwnedItems + .Where(static entry => entry.Value == LootAction.Salvage) + .Select(static entry => entry.Key) + .FirstOrDefault(ownedById.ContainsKey); + if (sourceId == 0u) + return false; + if (!canAct || !items.IsAvailable || items.IsBusy) + return true; + + const uint tinkeringTool = 0x20000000u; + PluginInventoryItem tool = owned.FirstOrDefault( + item => (item.ItemType & tinkeringTool) != 0u); + if (tool.ObjectId == 0u) + { + Status = "Salvage action is waiting for a salvage tool."; + return false; + } + + PluginInventoryItem source = ownedById[sourceId]; + PluginItemCommandResult result = items.Salvage(tool.ObjectId, [sourceId]); + if (!result.Accepted) + { + Status = result.Status == PluginItemCommandStatus.Busy + ? "Waiting to salvage…" + : $"Could not salvage {source.Name}."; + return result.Status == PluginItemCommandStatus.Busy; + } + _salvagePendingItem = sourceId; + _salvagePendingName = source.Name; + _stateAge = 0d; + Status = $"Salvaging {source.Name}…"; + return true; + } + + private bool ContinueSell(bool canAct) + { + IItemAutomation items = _host.Automation.Items; + IReadOnlyList owned = items.CaptureOwnedItems(); + if (_sellPendingItem != 0u) + { + if (!owned.Any(item => item.ObjectId == _sellPendingItem)) + { + RemoveClassifiedOwned(_sellPendingItem); + Status = $"Sold {_sellPendingName}."; + _sellPendingItem = 0u; + _sellPendingName = string.Empty; + _stateAge = 0d; + return true; + } + if (_stateAge < PickupTimeoutSeconds) + { + Status = $"Selling {_sellPendingName}…"; + return true; + } + _sellPendingItem = 0u; + _sellPendingName = string.Empty; + _stateAge = 0d; + } + + var ownedById = owned.ToDictionary(static item => item.ObjectId); + foreach (uint stale in _classifiedOwnedItems + .Where(entry => entry.Value == LootAction.Sell + && !ownedById.ContainsKey(entry.Key)) + .Select(static entry => entry.Key) + .ToArray()) + { + RemoveClassifiedOwned(stale); + } + uint sourceId = _classifiedOwnedItems + .Where(static entry => entry.Value == LootAction.Sell) + .Select(static entry => entry.Key) + .FirstOrDefault(ownedById.ContainsKey); + if (sourceId == 0u) + return false; + if (items.ActiveVendorObjectId == 0u) + { + Status = "Sell loot is queued until a vendor is open."; + return false; + } + if (!canAct || !items.IsAvailable || items.IsBusy) + return true; + + PluginInventoryItem source = ownedById[sourceId]; + PluginItemCommandResult result = items.Sell(sourceId); + if (!result.Accepted) + { + Status = result.Status == PluginItemCommandStatus.Busy + ? "Waiting to sell…" + : result.Notice ?? $"Could not sell {source.Name}."; + return result.Status == PluginItemCommandStatus.Busy; + } + _sellPendingItem = sourceId; + _sellPendingName = source.Name; + _stateAge = 0d; + Status = $"Selling {source.Name}…"; + return true; + } + + private bool HasManaStoneTransfer() + { + if (!_classifiedOwnedItems.Values.Contains(LootAction.ManaStone) + || !_classifiedOwnedItems.Values.Contains(LootAction.ManaTank)) + { + return false; + } + return ManaStoneTransferPlanner.Plan( + _host.Automation.Items.CaptureOwnedItems(), + _classifiedOwnedItems, + _settings.ManaTankMinimumMana) is not null; + } + + private bool ContinueManaStoneTransfer(bool canAct) + { + IItemAutomation items = _host.Automation.Items; + if (_manaTransfer is { } pending) + { + PluginItemUseCompletion completion = items.LastCompletion; + if (completion.Revision <= _manaTransferRevision + || completion.SourceObjectId != pending.StoneObjectId) + { + if (_stateAge < PickupTimeoutSeconds) + { + Status = $"Filling {pending.StoneName}…"; + return true; + } + Status = $"Mana stone fill timed out: {pending.StoneName}."; + } + else + { + Status = completion.IsSuccess + ? $"Filled {pending.StoneName}." + : $"Could not fill {pending.StoneName}."; + } + RemoveClassifiedOwned(pending.StoneObjectId); + RemoveClassifiedOwned(pending.TankObjectId); + _manaTransfer = null; + _manaTransferRevision = 0L; + _stateAge = 0d; + return true; + } + if (!canAct || !items.IsAvailable || items.IsBusy) + return true; + + ManaStoneTransferPlan? plan = ManaStoneTransferPlanner.Plan( + items.CaptureOwnedItems(), + _classifiedOwnedItems, + _settings.ManaTankMinimumMana); + if (plan is not { } next) + return false; + PluginItemCommandResult result = items.Apply( + next.StoneObjectId, + next.TankObjectId); + if (!result.Accepted) + { + Status = result.Status == PluginItemCommandStatus.Busy + ? "Waiting to fill mana stone…" + : $"Could not use {next.StoneName} on {next.TankName}."; + return result.Status == PluginItemCommandStatus.Busy; + } + _manaTransfer = next; + _manaTransferRevision = items.LastCompletion.Revision; + _stateAge = 0d; + Status = $"Filling {next.StoneName} from {next.TankName}…"; + return true; + } + + private bool HasSalvageBagCombine() => SalvageBagCombinePlanner.Plan( + _host.Automation.Items.CaptureOwnedItems(), + _abandonedCombineBags, + _settings.SalvageCombine) is not null; + + private bool ContinueSalvageBagCombine(bool canAct) + { + IItemAutomation items = _host.Automation.Items; + IReadOnlyList owned = items.CaptureOwnedItems(); + if (_combinePending is { } pending) + { + bool allExist = pending.ObjectIds.All( + id => owned.Any(item => item.ObjectId == id)); + if (!allExist) + { + foreach (uint id in pending.ObjectIds) + _combineAttempts.Remove(id); + _combinePending = null; + _stateAge = 0d; + Status = "Combined salvage bags."; + return true; + } + if (_stateAge < PickupTimeoutSeconds) + { + Status = "Combining salvage bags…"; + return true; + } + + _combineAttempts.TryGetValue(pending.FirstObjectId, out int attempts); + attempts++; + _combineAttempts[pending.FirstObjectId] = attempts; + if (attempts > 40) + { + _abandonedCombineBags.Add(pending.FirstObjectId); + _combineAttempts.Remove(pending.FirstObjectId); + Status = $"Abandoned bugged salvage bag {pending.FirstName}."; + } + else + { + Status = $"Retrying salvage combine ({attempts}/40)…"; + } + _combinePending = null; + _stateAge = 0d; + return attempts <= 40; + } + if (!canAct || !items.IsAvailable || items.IsBusy) + return true; + + SalvageBagCombinePlan? plan = SalvageBagCombinePlanner.Plan( + owned, + _abandonedCombineBags, + _settings.SalvageCombine); + if (plan is not { } next) + return false; + const uint tinkeringTool = 0x20000000u; + PluginInventoryItem tool = owned.FirstOrDefault( + item => (item.ItemType & tinkeringTool) != 0u); + if (tool.ObjectId == 0u) + { + Status = "Salvage combine is waiting for a salvage tool."; + return false; + } + PluginItemCommandResult result = items.Salvage( + tool.ObjectId, + next.ObjectIds); + if (!result.Accepted) + { + Status = result.Status == PluginItemCommandStatus.Busy + ? "Waiting to combine salvage…" + : "Could not combine salvage bags."; + return result.Status == PluginItemCommandStatus.Busy; + } + _combinePending = next; + _stateAge = 0d; + Status = "Combining salvage bags…"; + return true; + } + + private void IncrementAttempt(uint objectId) + { + _itemAttempts.TryGetValue(objectId, out int attempts); + _itemAttempts[objectId] = attempts + 1; + } + + private bool CanLoot(in PluginLootContainer corpse) + { + if (_settings.LootOnlyRareCorpses && !corpse.IsGeneratedRare) + return false; + string killer = KillerName(corpse.LongDescription); + string character = _host.Automation.Character.Name; + if (killer.Length != 0 + && character.Length != 0 + && string.Equals(killer, character, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // VTank never crosses ownership on a rare-generating corpse. Its + // fo.cs prioritizes the player's own rare corpse, but rejects a rare + // corpse whose killer is anyone else even after the public timer. + if (corpse.IsGeneratedRare) + return false; + + double firstSeen = _corpseFirstSeen.TryGetValue( + corpse.ObjectId, + out double value) ? value : _lifetime; + double age = _lifetime - firstSeen; + PluginFellowMember? fellow = _host.Automation.Fellowship + .CaptureMembers() + .FirstOrDefault(member => string.Equals( + member.Name, + killer, + StringComparison.OrdinalIgnoreCase)); + if (fellow is { ObjectId: not 0u } member) + { + if (!_settings.LootFellowCorpses) + return false; + return member.ShareLoot || age >= 100d; + } + + // VTank's fo.cs waits 100 seconds before treating an unrelated corpse + // as public, even when LootAllCorpses is enabled. + return _settings.LootAllCorpses && age >= 100d; + } + + private LootDecision? DecideItem( + in PluginInventoryItem item, + in PluginItemProperties properties, + IReadOnlyList owned, + IReadOnlyDictionary pending) + { + LootDecision? decision = string.IsNullOrWhiteSpace( + _settings.ExternalClassifierId) + ? LootRuleEngine.Decide( + item, + properties, + _settings.Rules, + owned, + pending, + _host) + : DecideWithExternalClassifier(item, properties, owned, pending); + if (decision is not null || !IsReadableUnknownScroll(item)) + { + if (decision is not null) + return decision; + } + else + { + return new LootDecision( + LootAction.Read, + Priority: 0, + RuleIndex: int.MaxValue, + RuleName: "Unknown Scroll"); + } + + LootAction? manaAction = AutomaticManaAction(item, owned); + return manaAction is { } action + ? new LootDecision( + action, + Priority: 0, + RuleIndex: int.MaxValue, + RuleName: action.ToString()) + : null; + } + + private LootDecision? DecideWithExternalClassifier( + in PluginInventoryItem item, + in PluginItemProperties properties, + IReadOnlyList owned, + IReadOnlyDictionary pending) + { + var context = new PluginLootClassificationContext( + item, + properties, + owned); + if (!_host.LootClassifiers.TryClassify( + _settings.ExternalClassifierId, + context, + out PluginLootClassification classification) + || !classification.Matched + || !Enum.IsDefined(classification.Action)) + { + return null; + } + + LootAction action = (LootAction)(int)classification.Action; + if (action == LootAction.NoLoot) + return null; + if (action == LootAction.KeepUpTo) + { + int limit = Math.Max(0, classification.KeepCount); + string itemName = item.Name; + int held = owned + .Where(ownedItem => string.Equals( + ownedItem.Name, + itemName, + StringComparison.OrdinalIgnoreCase)) + .Sum(static ownedItem => Math.Max(1, ownedItem.StackSize)); + if (pending.TryGetValue(itemName, out int pendingCount)) + held += pendingCount; + if (held >= limit) + return null; + } + + return new LootDecision( + action, + classification.Priority, + RuleIndex: -1, + RuleName: string.IsNullOrWhiteSpace(classification.RuleName) + ? _settings.ExternalClassifierId + : classification.RuleName.Trim(), + ClassifierId: _settings.ExternalClassifierId); + } + + private void RemoveClassifiedOwned(uint objectId) + { + _classifiedOwnedItems.Remove(objectId); + if (!_externalClassifierByItem.Remove(objectId, out string? classifierId)) + return; + _host.LootClassifiers.TryNotifyItemRemoved(classifierId, objectId); + } + + private void PruneRemovedExternalItems() + { + if (_externalClassifierByItem.Count == 0) + return; + HashSet owned = _host.Automation.Items.CaptureOwnedItems() + .Select(static item => item.ObjectId) + .ToHashSet(); + foreach (uint removed in _externalClassifierByItem.Keys + .Where(objectId => !owned.Contains(objectId)) + .ToArray()) + { + RemoveClassifiedOwned(removed); + } + } + + private LootAction? AutomaticManaAction( + in PluginInventoryItem item, + IReadOnlyList owned) + { + const uint manaStoneType = 0x00080000u; + const uint retainedFlag = 0x01000000u; + int desired = Math.Clamp(_settings.ManaStoneLootCount, 0, 100); + int stones = owned.Count(ownedItem => + (ownedItem.ItemType & manaStoneType) != 0u); + stones += _classifiedOwnedItems.Values.Count( + static action => action == LootAction.ManaStone); + if ((item.ItemType & manaStoneType) != 0u && stones < desired) + return LootAction.ManaStone; + if (stones < desired + && item.ItemCurrentMana >= Math.Clamp( + _settings.ManaTankMinimumMana, + 1, + int.MaxValue) + && item.Value != 0 + && (item.PublicFlags & retainedFlag) == 0u) + { + return LootAction.ManaTank; + } + return null; + } + + private bool IsReadableUnknownScroll(in PluginInventoryItem item) + { + if (!_settings.ReadUnknownScrolls + || item.SpellId == 0u + || _host.Automation.Spells.IsKnown(item.SpellId)) + { + return false; + } + + // Decal's ObjectClass.Scroll (42) is a derived client classification, + // not a field on retail PublicWeenieDesc. On the wire a scroll is the + // Misc item carrying one Spell DID; the name guard excludes casters + // and spell-bearing quest items that share those two qualities. + const uint miscItemType = 0x00000080u; + bool scrollShape = (item.ItemType & miscItemType) != 0u + && item.Name.EndsWith(" Scroll", StringComparison.OrdinalIgnoreCase); + if (!scrollShape + || !_host.Automation.Spells.TryGet(item.SpellId, out PluginSpellInfo spell)) + { + return false; + } + return _host.Automation.Character.TryGetSkill( + spell.School, + out PluginSkillInfo skill) + && spell.Difficulty - 15 <= skill.Current; + } + + private void BlacklistFailedCorpse(uint corpseId) + { + if (corpseId == 0u) + return; + _corpseOpenAttempts.TryGetValue(corpseId, out int attempts); + attempts++; + int threshold = Math.Clamp( + _settings.BlacklistCorpseOpenAttemptCount, + 1, + 1000); + if (attempts < threshold) + { + _corpseOpenAttempts[corpseId] = attempts; + Status = $"Retrying corpse ({attempts}/{threshold})…"; + return; + } + _corpseOpenAttempts.Remove(corpseId); + _corpseBlacklistedAt[corpseId] = _lifetime; + Status = $"Blacklisted unopenable corpse for " + + $"{Math.Clamp(_settings.BlacklistCorpseOpenTimeoutSeconds, 1d, 3600d):0} seconds."; + } + + private bool IsCorpseBlacklisted(uint corpseId) + { + if (!_corpseBlacklistedAt.TryGetValue(corpseId, out double since)) + return false; + double timeout = Math.Clamp( + _settings.BlacklistCorpseOpenTimeoutSeconds, + 1d, + 3600d); + if (_lifetime - since < timeout) + return true; + _corpseBlacklistedAt.Remove(corpseId); + return false; + } + + private void MarkCorpseComplete(uint corpseId) + { + if (corpseId == 0u) + return; + _completedCorpses[corpseId] = _lifetime; + _corpseOpenAttempts.Remove(corpseId); + _corpseBlacklistedAt.Remove(corpseId); + } + + private void PruneCorpseCache() + { + double expiry = Math.Clamp( + _settings.CorpseCacheTimeoutMinutes, + 1d, + 1440d) * 60d; + foreach (uint id in _completedCorpses + .Where(entry => _lifetime - entry.Value >= expiry) + .Select(static entry => entry.Key) + .ToArray()) + { + _completedCorpses.Remove(id); + _corpseFirstSeen.Remove(id); + } + } + + internal static string KillerName(string description) + { + const string prefix = "Killed by "; + if (string.IsNullOrWhiteSpace(description) + || !description.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return string.Empty; + } + string remainder = description[prefix.Length..]; + int period = remainder.IndexOf('.'); + if (period >= 0) + remainder = remainder[..period]; + return remainder.Trim(); + } + + private void ResetTransient() + { + _activeCorpse = 0u; + _activeCorpseSawContents = false; + _waitingItem = 0u; + _waitingName = string.Empty; + _waitingAction = LootAction.NoLoot; + _waitingQuantity = 0; + _waitingItemSnapshot = default; + _waitingClassifierId = string.Empty; + _waitingInventoryRevision = 0L; + _awaitingAppraisal = 0u; + _awaitingCorpseAppraisal = 0u; + _postUseItem = 0u; + _postUseName = string.Empty; + _postUseStarted = false; + _postUseRevision = 0L; + _salvagePendingItem = 0u; + _salvagePendingName = string.Empty; + _salvageAttempts = 0; + _sellPendingItem = 0u; + _sellPendingName = string.Empty; + _manaTransfer = null; + _manaTransferRevision = 0L; + _combinePending = null; + _stateAge = 0d; + } +} + +/// +/// Compact ordered loot-rule expression. OR groups contain AND clauses; each +/// clause compares one named item field or raw property table entry. +/// +internal sealed class LootRuleExpression +{ + private readonly Clause[][] _groups; + + private LootRuleExpression(Clause[][] groups) => _groups = groups; + + public static LootRuleExpression Compile(string source) + { + ArgumentException.ThrowIfNullOrWhiteSpace(source); + string normalized = source.Trim(); + if (normalized is "*" || normalized.Equals( + "DEFAULT", + StringComparison.OrdinalIgnoreCase)) + { + return new LootRuleExpression([[]]); + } + + Clause[][] groups = Split(normalized, "||") + .Select(group => Split(group, "&&") + .Select(ParseClause) + .ToArray()) + .ToArray(); + if (groups.Length == 0 || groups.Any(static group => group.Length == 0)) + throw new FormatException("Loot expression contains an empty condition."); + return new LootRuleExpression(groups); + } + + public bool IsMatch( + in PluginInventoryItem item, + in PluginItemProperties properties) + { + if (_groups.Length == 1 && _groups[0].Length == 0) + return true; + foreach (Clause[] group in _groups) + { + bool all = true; + foreach (Clause clause in group) + { + if (!clause.IsMatch(item, properties)) + { + all = false; + break; + } + } + if (all) + return true; + } + return false; + } + + private static Clause ParseClause(string text) + { + foreach (string operation in new[] { ">=", "<=", "!=", "==", "~=", ">", "<" }) + { + int offset = FindOutsideQuotes(text, operation); + if (offset < 0) + continue; + string field = text[..offset].Trim(); + string expected = Unquote(text[(offset + operation.Length)..].Trim()); + if (field.Length == 0 || expected.Length == 0) + throw new FormatException($"Invalid loot condition '{text.Trim()}'."); + return new Clause(field, operation, expected); + } + throw new FormatException( + $"Loot condition '{text.Trim()}' needs a comparison operator."); + } + + private static string[] Split(string source, string delimiter) + { + var result = new List(); + int start = 0; + char quote = '\0'; + for (int index = 0; index <= source.Length - delimiter.Length; index++) + { + char current = source[index]; + if (current is '\'' or '"') + quote = quote == '\0' ? current : quote == current ? '\0' : quote; + if (quote != '\0' + || !source.AsSpan(index).StartsWith( + delimiter, + StringComparison.Ordinal)) + { + continue; + } + result.Add(source[start..index].Trim()); + start = index + delimiter.Length; + index += delimiter.Length - 1; + } + result.Add(source[start..].Trim()); + return result.ToArray(); + } + + private static int FindOutsideQuotes(string source, string operation) + { + char quote = '\0'; + for (int index = 0; index <= source.Length - operation.Length; index++) + { + char current = source[index]; + if (current is '\'' or '"') + quote = quote == '\0' ? current : quote == current ? '\0' : quote; + if (quote == '\0' + && source.AsSpan(index).StartsWith( + operation, + StringComparison.Ordinal)) + { + return index; + } + } + return -1; + } + + private static string Unquote(string value) => value.Length >= 2 + && ((value[0] == '"' && value[^1] == '"') + || (value[0] == '\'' && value[^1] == '\'')) + ? value[1..^1] + : value; + + private readonly record struct Clause( + string Field, + string Operation, + string Expected) + { + public bool IsMatch( + in PluginInventoryItem item, + in PluginItemProperties properties) + { + Value actual = Resolve(item, properties, Field); + if (Operation == "~=") + { + if (actual.Kind != ValueKind.Text) + throw new FormatException("~= is only valid for text fields."); + return actual.Text.Contains(Expected, StringComparison.OrdinalIgnoreCase); + } + int comparison = actual.Kind switch + { + ValueKind.Number => actual.Number.CompareTo(ParseNumber(Expected)), + ValueKind.Boolean => actual.Boolean.CompareTo(ParseBoolean(Expected)), + _ => string.Compare( + actual.Text, + Expected, + StringComparison.OrdinalIgnoreCase), + }; + return Operation switch + { + "==" => comparison == 0, + "!=" => comparison != 0, + ">" => comparison > 0, + "<" => comparison < 0, + ">=" => comparison >= 0, + "<=" => comparison <= 0, + _ => false, + }; + } + + private static Value Resolve( + in PluginInventoryItem item, + in PluginItemProperties properties, + string field) + { + string key = field.Trim().ToLowerInvariant(); + return key switch + { + "name" => Value.FromText(item.Name), + "wcid" or "typeid" => Value.FromNumber(item.WeenieClassId), + "itemtype" or "type" => Value.FromNumber(item.ItemType), + "stack" or "stacksize" => Value.FromNumber(item.StackSize), + "maxstack" => Value.FromNumber(item.MaximumStackSize), + "value" => Value.FromNumber(item.Value), + "burden" => Value.FromNumber(item.Burden), + "workmanship" => Value.FromNumber(item.Workmanship), + "material" => Value.FromNumber(item.MaterialType), + _ => ResolveRaw(key, properties), + }; + } + + private static Value ResolveRaw( + string field, + in PluginItemProperties properties) + { + if (!TryRawKey(field, out string table, out uint key)) + throw new FormatException($"Unknown loot field '{field}'."); + return table switch + { + "int" => Value.FromNumber( + properties.Ints?.TryGetValue(key, out int value) == true + ? value : 0), + "int64" => Value.FromNumber( + properties.Int64s?.TryGetValue(key, out long value) == true + ? value : 0), + "bool" => Value.FromBoolean( + properties.Bools?.TryGetValue(key, out bool value) == true + && value), + "float" => Value.FromNumber( + properties.Floats?.TryGetValue(key, out double value) == true + ? value : 0d), + "string" => Value.FromText( + properties.Strings?.TryGetValue(key, out string? value) == true + ? value : string.Empty), + "did" => Value.FromNumber( + properties.DataIds?.TryGetValue(key, out uint value) == true + ? value : 0u), + "iid" => Value.FromNumber( + properties.InstanceIds?.TryGetValue(key, out uint value) == true + ? value : 0u), + _ => throw new FormatException($"Unknown raw table '{table}'."), + }; + } + + private static bool TryRawKey( + string field, + out string table, + out uint key) + { + int open = field.IndexOf('[', StringComparison.Ordinal); + int close = field.LastIndexOf(']'); + table = open > 0 ? field[..open] : string.Empty; + key = 0u; + return open > 0 && close == field.Length - 1 + && uint.TryParse( + field.AsSpan(open + 1, close - open - 1), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out key); + } + + private static double ParseNumber(string value) => + double.TryParse( + value, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out double parsed) + ? parsed + : throw new FormatException($"'{value}' is not a number."); + + private static bool ParseBoolean(string value) => + bool.TryParse(value, out bool parsed) + ? parsed + : throw new FormatException($"'{value}' is not true or false."); + } + + private enum ValueKind + { + Number, + Text, + Boolean, + } + + private readonly record struct Value( + ValueKind Kind, + double Number, + string Text, + bool Boolean) + { + public static Value FromNumber(double value) => + new(ValueKind.Number, value, string.Empty, false); + public static Value FromText(string value) => + new(ValueKind.Text, 0d, value ?? string.Empty, false); + public static Value FromBoolean(bool value) => + new(ValueKind.Boolean, 0d, string.Empty, value); + } +} diff --git a/src/AcDream.Plugins.MossTank/Meta.cs b/src/AcDream.Plugins.MossTank/Meta.cs new file mode 100644 index 00000000..e361d4b6 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Meta.cs @@ -0,0 +1,656 @@ +using System.Text.RegularExpressions; +using AcDream.Plugin.Abstractions; +using AcDream.Plugins.MossTank.Expressions; + +namespace AcDream.Plugins.MossTank; + +internal enum MetaConditionKind +{ + Never, + Always, + All, + Any, + ChatMessage, + PackSlotsLessThanOrEqual, + SecondsInStateGreaterThanOrEqual, + NavigationRouteEmpty, + CharacterDeath, + AnyVendorOpen, + VendorClosed, + InventoryItemCountLessThanOrEqual, + InventoryItemCountGreaterThanOrEqual, + MonsterNameCountWithinDistance, + MonsterPriorityCountWithinDistance, + NeedToBuff, + NoMonstersWithinDistance, + LandblockEquals, + LandcellEquals, + PortalspaceEntered, + PortalspaceExited, + Not, + PersistentSecondsInStateGreaterThanOrEqual, + TimeLeftOnSpellGreaterThanOrEqual, + BurdenPercentGreaterThanOrEqual, + DistanceFromAnyRoutePointGreaterThanOrEqual, + Expression, + ChatMessageCapture, +} + +internal enum MetaActionKind +{ + None, + SetMetaState, + ChatCommand, + All, + LoadEmbeddedNavigationRoute, + CallMetaState, + ReturnFromCall, + ExpressionAction, + ChatExpression, + SetWatchdog, + ClearWatchdog, + GetVtankOption, + SetVtankOption, + CreateView, + DestroyView, + DestroyAllViews, +} + +internal sealed class MetaCondition +{ + public MetaConditionKind Kind { get; set; } = MetaConditionKind.Always; + public string Text { get; set; } = string.Empty; + public string SecondaryText { get; set; } = string.Empty; + public double Number { get; set; } + public double SecondaryNumber { get; set; } + public double TertiaryNumber { get; set; } + public List Children { get; set; } = []; + + public static MetaCondition Always() => new() { Kind = MetaConditionKind.Always }; +} + +internal sealed class MetaAction +{ + public MetaActionKind Kind { get; set; } = MetaActionKind.None; + public string Text { get; set; } = string.Empty; + public string SecondaryText { get; set; } = string.Empty; + public double Number { get; set; } + public double SecondaryNumber { get; set; } + public List Children { get; set; } = []; +} + +internal sealed class MetaRule +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string State { get; set; } = MetaEngine.DefaultState; + public MetaCondition Condition { get; set; } = MetaCondition.Always(); + public MetaAction Action { get; set; } = new(); + public bool Enabled { get; set; } = true; +} + +internal sealed class MetaProfile +{ + public List Rules { get; set; } = []; +} + +/// Bridges engine behavior to the already-owned MossTank controllers. +internal sealed class MetaServices +{ + public Func IsNavigationRouteEmpty { get; init; } = static () => true; + public Func NeedsBuff { get; init; } = static () => false; + public Func DistanceFromAnyRoutePoint { get; init; } = + static () => double.PositiveInfinity; + public Func CountMonstersByPriority { get; init; } = + static (_, _) => 0; + public Action LoadEmbeddedNavigationRoute { get; init; } = static _ => { }; + public Func GetOption { get; init; } = + static _ => ExpressionValue.Zero; + public Func SetOption { get; init; } = + static (_, _) => false; + public Func CreateView { get; init; } = + static (_, _) => false; + public Func DestroyView { get; init; } = static _ => false; + public Action DestroyAllViews { get; init; } = static () => { }; +} + +/// +/// VTank's ordered Meta engine: state-local rules fire once per state entry, +/// actions may continue the same pass, and transitions/calls stop the pass. +/// +internal sealed class MetaEngine +{ + public const string DefaultState = "Default"; + public const double DecisionIntervalSeconds = 0.293d; + public const int MaximumCallDepth = 10_000; + private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(100); + + private readonly IPluginHost _host; + private readonly MossTankExpressionRuntime _expressions; + private readonly MetaServices _services; + private readonly HashSet _fired = []; + private readonly Stack _callStack = []; + private readonly List _chatBatch = []; + private MetaProfile _profile; + private double _decisionAccumulator; + private double _stateSeconds; + private double _persistentStateSeconds; + private ulong _chatSequence; + private bool _wasPortalSpace; + private bool _wasDead; + private bool _portalEntered; + private bool _portalExited; + private bool _deathEdge; + private Watchdog? _watchdog; + private string _status = "Meta disabled."; + + public MetaEngine( + IPluginHost host, + MossTankExpressionRuntime expressions, + MetaProfile profile, + MetaServices? services = null) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _expressions = expressions ?? throw new ArgumentNullException(nameof(expressions)); + _profile = profile ?? throw new ArgumentNullException(nameof(profile)); + _services = services ?? new MetaServices(); + _wasPortalSpace = host.Automation.Navigation.Snapshot.IsPortalSpace; + _wasDead = IsDead(); + } + + public bool Enabled { get; private set; } + public string CurrentState { get; private set; } = DefaultState; + public string Status => _status; + public int CallDepth => _callStack.Count; + public int FiredRuleCount => _fired.Count; + public IReadOnlyCollection States => _profile.Rules + .Select(static rule => NormalizeState(rule.State)) + .Append(DefaultState) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + public void SetEnabled(bool enabled) + { + if (Enabled == enabled) + return; + Enabled = enabled; + if (enabled) + { + _stateSeconds = 0d; + _decisionAccumulator = DecisionIntervalSeconds; + _status = $"Meta running: {CurrentState}."; + } + else + { + _status = "Meta disabled."; + _watchdog = null; + } + } + + /// + /// Ends the complete VTank meta-session lifetime. A graphical plugin may + /// survive logout and reconnect, but call stacks, once-per-entry receipts, + /// chat cursors, portal/death edges and state timers must not cross that + /// boundary into the next character session. + /// + public void ResetSession() + { + Enabled = false; + CurrentState = DefaultState; + _fired.Clear(); + _callStack.Clear(); + _chatBatch.Clear(); + _decisionAccumulator = 0d; + _stateSeconds = 0d; + _persistentStateSeconds = 0d; + _chatSequence = 0u; + _wasPortalSpace = _host.Automation.Navigation.Snapshot.IsPortalSpace; + _wasDead = IsDead(); + _portalEntered = false; + _portalExited = false; + _deathEdge = false; + _watchdog = null; + _status = "Meta disabled."; + } + + public void ReplaceProfile(MetaProfile profile) + { + _profile = profile ?? throw new ArgumentNullException(nameof(profile)); + Transition(DefaultState); + } + + public void Transition(string state) + { + CurrentState = NormalizeState(state); + _fired.Clear(); + _stateSeconds = 0d; + _persistentStateSeconds = 0d; + _watchdog = null; + _status = $"Meta transitioned to {CurrentState}."; + } + + public void OnTick(double elapsedSeconds) + { + if (elapsedSeconds < 0d || !double.IsFinite(elapsedSeconds)) + throw new ArgumentOutOfRangeException(nameof(elapsedSeconds)); + _expressions.OnTick(elapsedSeconds); + CaptureEdgesAndChat(); + if (!Enabled) + return; + + _stateSeconds += elapsedSeconds; + _persistentStateSeconds += elapsedSeconds; + _decisionAccumulator += elapsedSeconds; + UpdateWatchdog(elapsedSeconds); + if (_decisionAccumulator < DecisionIntervalSeconds) + return; + _decisionAccumulator %= DecisionIntervalSeconds; + EvaluatePass(); + _portalEntered = false; + _portalExited = false; + _deathEdge = false; + _chatBatch.Clear(); + } + + public void EvaluatePass() + { + if (!Enabled) + return; + if (WatchdogExpired()) + { + if (_callStack.Count >= MaximumCallDepth) + { + DisableWithError("Meta Error: Call stack overflow (watchdog loop?)."); + return; + } + string target = _watchdog!.Value.State; + _callStack.Push(CurrentState); + Transition(target); + _status = $"Meta watchdog expired; calling {target}."; + return; + } + + MetaRule[] rules = _profile.Rules.Where(rule => + rule.Enabled + && NormalizeState(rule.State).Equals( + CurrentState, + StringComparison.OrdinalIgnoreCase)).ToArray(); + foreach (MetaRule rule in rules) + { + if (_fired.Contains(rule.Id) || !EvaluateCondition(rule.Condition)) + continue; + _fired.Add(rule.Id); + _status = $"Meta executing {Describe(rule.Action)}."; + bool continuePass; + try + { + continuePass = ExecuteAction(rule.Action); + } + catch (Exception error) + { + _status = $"Meta action failed: {error.Message}"; + _host.Log.Error(_status, error); + continuePass = false; + } + if (!continuePass) + break; + } + } + + /// Command-only test hook matching VTank's /vt fakedeath. + internal void TriggerFakeDeath() + { + _deathEdge = true; + if (Enabled) + EvaluatePass(); + _deathEdge = false; + } + + private bool EvaluateCondition(MetaCondition condition) => condition.Kind switch + { + MetaConditionKind.Never => false, + MetaConditionKind.Always => true, + MetaConditionKind.All => condition.Children.All(EvaluateCondition), + MetaConditionKind.Any => condition.Children.Any(EvaluateCondition), + MetaConditionKind.Not => condition.Children.Count != 0 + && !EvaluateCondition(condition.Children[0]), + MetaConditionKind.ChatMessage => ChatMatch(condition, capture: false), + MetaConditionKind.ChatMessageCapture => ChatMatch(condition, capture: true), + MetaConditionKind.PackSlotsLessThanOrEqual => + EvaluateNumber("getfreeitemslots[]") <= condition.Number, + MetaConditionKind.SecondsInStateGreaterThanOrEqual => + _stateSeconds >= condition.Number, + MetaConditionKind.PersistentSecondsInStateGreaterThanOrEqual => + _persistentStateSeconds >= condition.Number, + MetaConditionKind.NavigationRouteEmpty => _services.IsNavigationRouteEmpty(), + MetaConditionKind.CharacterDeath => _deathEdge, + MetaConditionKind.AnyVendorOpen => hostItems().ActiveVendorObjectId != 0u, + MetaConditionKind.VendorClosed => hostItems().ActiveVendorObjectId == 0u, + MetaConditionKind.InventoryItemCountLessThanOrEqual => + InventoryCount(condition.Text) <= condition.Number, + MetaConditionKind.InventoryItemCountGreaterThanOrEqual => + InventoryCount(condition.Text) >= condition.Number, + MetaConditionKind.MonsterNameCountWithinDistance => + MonsterCount(condition.Text, condition.SecondaryNumber) >= condition.Number, + MetaConditionKind.MonsterPriorityCountWithinDistance => + _services.CountMonstersByPriority( + checked((int)condition.TertiaryNumber), + condition.SecondaryNumber) >= condition.Number, + MetaConditionKind.NeedToBuff => _services.NeedsBuff(), + MetaConditionKind.NoMonstersWithinDistance => + _host.Automation.Combat.CaptureHostileTargets( + checked((float)condition.Number)).Count == 0, + MetaConditionKind.LandblockEquals => + (_host.Automation.Navigation.Snapshot.Position.CellId & 0xFFFF0000u) + == unchecked((uint)checked((int)condition.Number)), + MetaConditionKind.LandcellEquals => + _host.Automation.Navigation.Snapshot.Position.CellId + == unchecked((uint)checked((int)condition.Number)), + MetaConditionKind.PortalspaceEntered => _portalEntered, + MetaConditionKind.PortalspaceExited => _portalExited, + MetaConditionKind.TimeLeftOnSpellGreaterThanOrEqual => + SpellTimeLeft(condition) >= condition.SecondaryNumber, + MetaConditionKind.BurdenPercentGreaterThanOrEqual => + EvaluateNumber("getcharburden[]") >= condition.Number, + MetaConditionKind.DistanceFromAnyRoutePointGreaterThanOrEqual => + _services.DistanceFromAnyRoutePoint() >= condition.Number, + MetaConditionKind.Expression => + _expressions.Evaluate(condition.Text).IsTruthy, + _ => false, + }; + + private bool ExecuteAction(MetaAction action) + { + switch (action.Kind) + { + case MetaActionKind.None: + return true; + case MetaActionKind.SetMetaState: + Transition(action.Text); + return false; + case MetaActionKind.ChatCommand: + _host.Automation.Chat.Submit(action.Text); + return true; + case MetaActionKind.All: + foreach (MetaAction child in action.Children) + { + if (!ExecuteAction(child)) + return false; + } + return true; + case MetaActionKind.LoadEmbeddedNavigationRoute: + _services.LoadEmbeddedNavigationRoute(action.Text); + return true; + case MetaActionKind.CallMetaState: + if (_callStack.Count >= MaximumCallDepth) + { + DisableWithError("Meta Error: Call stack overflow (recursive call loop?)."); + return false; + } + _callStack.Push(string.IsNullOrWhiteSpace(action.SecondaryText) + ? CurrentState + : NormalizeState(action.SecondaryText)); + Transition(action.Text); + return false; + case MetaActionKind.ReturnFromCall: + if (_callStack.Count == 0) + { + DisableWithError("Meta Error: Call stack underflow, cannot return."); + return false; + } + Transition(_callStack.Pop()); + return false; + case MetaActionKind.ExpressionAction: + _expressions.Evaluate(action.Text); + return true; + case MetaActionKind.ChatExpression: + ExpressionValue result = _expressions.Evaluate(action.Text); + if (result.ToDisplayString().Length != 0) + _host.Automation.Chat.Submit(result.ToDisplayString()); + return true; + case MetaActionKind.SetWatchdog: + SetWatchdog( + action.Text, + action.Number <= 0d ? 5d : action.Number, + action.SecondaryNumber <= 0d ? 10d : action.SecondaryNumber); + return true; + case MetaActionKind.ClearWatchdog: + _watchdog = null; + return true; + case MetaActionKind.GetVtankOption: + _expressions.State.Set( + ExpressionVariableScope.Session, + string.IsNullOrWhiteSpace(action.SecondaryText) + ? "option" + : action.SecondaryText, + _services.GetOption(action.Text)); + return true; + case MetaActionKind.SetVtankOption: + return _services.SetOption( + action.Text, + _expressions.Evaluate(action.SecondaryText)); + case MetaActionKind.CreateView: + return _services.CreateView(action.Text, action.SecondaryText); + case MetaActionKind.DestroyView: + return _services.DestroyView(action.Text); + case MetaActionKind.DestroyAllViews: + _services.DestroyAllViews(); + return true; + default: + return false; + } + } + + private void CaptureEdgesAndChat() + { + bool portal = _host.Automation.Navigation.Snapshot.IsPortalSpace; + _portalEntered |= !_wasPortalSpace && portal; + _portalExited |= _wasPortalSpace && !portal; + _wasPortalSpace = portal; + bool dead = IsDead(); + _deathEdge |= !_wasDead && dead; + _wasDead = dead; + + IReadOnlyList messages = + _host.Automation.Chat.CaptureMessages(_chatSequence); + foreach (PluginChatMessage message in messages) + { + _chatBatch.Add(message); + _chatSequence = Math.Max(_chatSequence, message.Sequence); + } + } + + private bool ChatMatch(MetaCondition condition, bool capture) + { + Regex regex; + try + { + regex = new Regex( + condition.Text, + RegexOptions.CultureInvariant, + RegexTimeout); + } + catch (ArgumentException) + { + return false; + } + HashSet? acceptedKinds = ParseKinds(condition.SecondaryText); + foreach (PluginChatMessage message in _chatBatch) + { + if (acceptedKinds is not null && !acceptedKinds.Contains(message.Kind)) + continue; + Match match = regex.Match(message.Text); + if (!match.Success) + continue; + if (capture) + { + foreach (string name in regex.GetGroupNames()) + { + Group group = match.Groups[name]; + string variable = "capturegroup_" + name; + if (group.Success) + { + _expressions.State.Set( + ExpressionVariableScope.Session, + variable, + ExpressionValue.String(group.Value)); + } + else + { + _expressions.State.Clear( + ExpressionVariableScope.Session, + variable); + } + } + _expressions.State.Set( + ExpressionVariableScope.Session, + "capturecolor", + ExpressionValue.Number(message.Kind)); + } + return true; + } + return false; + } + + private static HashSet? ParseKinds(string source) + { + if (string.IsNullOrWhiteSpace(source)) + return null; + var result = new HashSet(); + foreach (string part in source.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + if (!int.TryParse(part.Trim(), out int kind)) + return []; + result.Add(kind); + } + return result; + } + + private double InventoryCount(string name) + { + string escaped = name.Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("'", "\\'", StringComparison.Ordinal); + return EvaluateNumber($"getitemcountininventorybyname['{escaped}']"); + } + + private int MonsterCount(string pattern, double distance) + { + Regex regex; + try + { + regex = new Regex( + pattern, + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, + RegexTimeout); + } + catch (ArgumentException) + { + return 0; + } + return _host.Automation.Combat.CaptureHostileTargets( + checked((float)distance)).Count(target => regex.IsMatch(target.Name)); + } + + private double SpellTimeLeft(MetaCondition condition) + { + uint spellId = condition.Number > 0d + ? checked((uint)condition.Number) + : _host.Automation.Spells.KnownSelfBuffs + .Concat(_host.Automation.Spells.KnownCombatSpells) + .FirstOrDefault(spell => spell.Name.Equals( + condition.Text, + StringComparison.OrdinalIgnoreCase)).SpellId; + foreach (PluginActiveEnchantment enchantment in + _host.Automation.Character.ActiveEnchantments) + { + if (enchantment.SpellId == spellId) + return enchantment.SecondsRemaining; + } + return 0d; + } + + private double EvaluateNumber(string source) => + _expressions.Evaluate(source).AsNumber(source); + + private IItemAutomation hostItems() => _host.Automation.Items; + + private bool IsDead() + { + ICharacterInfo character = _host.Automation.Character; + return character.IsInWorld + && character.MaxHealth > 0u + && character.CurrentHealth == 0u; + } + + private void SetWatchdog(string state, double rangeMeters, double seconds) + { + PluginNavigationPosition position = + _host.Automation.Navigation.Snapshot.Position; + _watchdog = new Watchdog( + NormalizeState(state), + Math.Max(0d, rangeMeters), + Math.Max(0.001d, seconds), + 0d, + 0d, + Enumerable.Repeat(position, 10).ToArray()); + } + + private void UpdateWatchdog(double elapsedSeconds) + { + if (_watchdog is not Watchdog watchdog) + return; + watchdog = watchdog with + { + TotalSeconds = watchdog.TotalSeconds + elapsedSeconds, + SampleSeconds = watchdog.SampleSeconds + elapsedSeconds, + }; + double interval = watchdog.TimeSpanSeconds / 10d; + if (watchdog.SampleSeconds >= interval) + { + int index = ((int)Math.Floor(watchdog.TotalSeconds / interval)) % 10; + watchdog.Samples[index] = _host.Automation.Navigation.Snapshot.Position; + watchdog = watchdog with { SampleSeconds = watchdog.SampleSeconds % interval }; + } + _watchdog = watchdog; + } + + private bool WatchdogExpired() + { + if (_watchdog is not Watchdog watchdog + || watchdog.TotalSeconds < watchdog.TimeSpanSeconds) + { + return false; + } + PluginNavigationPosition current = + _host.Automation.Navigation.Snapshot.Position; + return watchdog.Samples.All(sample => + sample.HorizontalDistanceMeters(current) <= watchdog.RangeMeters); + } + + private void DisableWithError(string message) + { + Enabled = false; + _status = message + " Meta disabled."; + _host.Automation.Chat.PostSystemMessage(_status); + _host.Log.Error(_status); + } + + private static string NormalizeState(string? state) => + string.IsNullOrWhiteSpace(state) ? DefaultState : state.Trim(); + + private static string Describe(MetaAction action) => action.Kind switch + { + MetaActionKind.SetMetaState => $"Set Meta State {action.Text}", + MetaActionKind.CallMetaState => $"Call Meta State {action.Text}", + MetaActionKind.ChatCommand => $"Chat {action.Text}", + _ => action.Kind.ToString(), + }; + + private readonly record struct Watchdog( + string State, + double RangeMeters, + double TimeSpanSeconds, + double TotalSeconds, + double SampleSeconds, + PluginNavigationPosition[] Samples); +} diff --git a/src/AcDream.Plugins.MossTank/MetaViewManager.cs b/src/AcDream.Plugins.MossTank/MetaViewManager.cs new file mode 100644 index 00000000..54ad920a --- /dev/null +++ b/src/AcDream.Plugins.MossTank/MetaViewManager.cs @@ -0,0 +1,97 @@ +using System.Security.Cryptography; +using System.Text; +using System.Xml.Linq; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// Owns VTank Meta-created views independently from the macro window. The +/// official implementation replaces duplicate names and permits five normal +/// entries (including its historical sixth-entry boundary quirk). +/// +internal sealed class MetaViewManager +{ + private const int OfficialViewLimit = 5; + private readonly IPluginHost _host; + private readonly Dictionary _views = + new(StringComparer.Ordinal); + + public MetaViewManager(IPluginHost host) => + _host = host ?? throw new ArgumentNullException(nameof(host)); + + public int Count => _views.Count; + + public bool Create(string name, string markup) + { + if (!_host.HasUi || string.IsNullOrEmpty(name) || string.IsNullOrEmpty(markup)) + return false; + + // bw.a(string,string) checks Count > 5 before duplicate replacement. + // Preserve that observable VTank quirk for imported Meta profiles. + if (_views.Count > OfficialViewLimit) + return false; + + try + { + XElement root = XDocument.Parse(markup).Root + ?? throw new InvalidDataException("View markup has no root element."); + if (!root.Name.LocalName.Equals("panel", StringComparison.OrdinalIgnoreCase)) + return false; + } + catch (Exception error) when (error is InvalidDataException or System.Xml.XmlException) + { + _host.Log.Warn($"MossTank Meta view '{name}' is invalid: {error.Message}"); + return false; + } + + Destroy(name); + IDisposable token = _host.Ui.RegisterPanelContent( + new PluginPanelDescriptor(WindowId(name), name) + { + IconText = Initials(name), + StartVisible = true, + ShowInSidePanel = true, + }, + markup, + MetaViewBinding.Instance); + _views.Add(name, token); + return true; + } + + public bool Destroy(string name) + { + if (!_views.Remove(name, out IDisposable? registration)) + return false; + registration.Dispose(); + return true; + } + + public void DestroyAll() + { + IDisposable[] registrations = _views.Values.ToArray(); + _views.Clear(); + foreach (IDisposable registration in registrations) + registration.Dispose(); + } + + private static string WindowId(string name) + { + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(name)); + return "meta-" + Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant(); + } + + private static string Initials(string name) + { + string[] words = name.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (words.Length == 0) + return "M"; + return string.Concat(words.Take(2).Select(static word => word[0])).ToUpperInvariant(); + } + + private sealed class MetaViewBinding + { + internal static MetaViewBinding Instance { get; } = new(); + public bool WindowAvailable => true; + } +} diff --git a/src/AcDream.Plugins.MossTank/MonsterExpression.cs b/src/AcDream.Plugins.MossTank/MonsterExpression.cs new file mode 100644 index 00000000..98c9c8e8 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/MonsterExpression.cs @@ -0,0 +1,608 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace AcDream.Plugins.MossTank; + +internal enum MonsterValueKind +{ + Number, + Text, + Boolean, +} + +/// +/// One value in VTank's monster-list expression language. Unlike meta +/// expressions, monster expressions have a real boolean type and require both +/// operands of a comparison to have the same type. +/// +internal readonly record struct MonsterValue +{ + private MonsterValue( + MonsterValueKind kind, + double number, + string? text, + bool boolean) + { + Kind = kind; + Number = number; + Text = text ?? string.Empty; + Boolean = boolean; + } + + public MonsterValueKind Kind { get; } + public double Number { get; } + public string Text { get; } + public bool Boolean { get; } + + public static MonsterValue FromNumber(double value) => + new(MonsterValueKind.Number, value, null, false); + + public static MonsterValue FromText(string value) => + new(MonsterValueKind.Text, 0d, value, false); + + public static MonsterValue FromBoolean(bool value) => + new(MonsterValueKind.Boolean, 0d, null, value); + + public override string ToString() => Kind switch + { + MonsterValueKind.Number => Number.ToString(CultureInfo.InvariantCulture), + MonsterValueKind.Text => Text, + MonsterValueKind.Boolean => Boolean ? "true" : "false", + _ => string.Empty, + }; +} + +/// Live values exposed by VTank's /vt listmonstervariables. +internal readonly record struct MonsterExpressionContext( + string Name, + uint TypeId, + string Species, + int MaximumHealth, + float Range, + bool HasShield, + string MetaState, + Func? Setting = null) +{ + internal bool TryResolve(string token, out MonsterValue value) + { + if (token.Equals("true", StringComparison.OrdinalIgnoreCase)) + { + value = MonsterValue.FromBoolean(true); + return true; + } + if (token.Equals("false", StringComparison.OrdinalIgnoreCase)) + { + value = MonsterValue.FromBoolean(false); + return true; + } + if (token.Equals("name", StringComparison.OrdinalIgnoreCase)) + { + value = MonsterValue.FromText(Name); + return true; + } + if (token.Equals("typeid", StringComparison.OrdinalIgnoreCase)) + { + value = MonsterValue.FromNumber(TypeId); + return true; + } + if (token.Equals("species", StringComparison.OrdinalIgnoreCase)) + { + value = MonsterValue.FromText(Species); + return true; + } + if (token.Equals("maxhp", StringComparison.OrdinalIgnoreCase)) + { + value = MonsterValue.FromNumber(MaximumHealth); + return true; + } + if (token.Equals("range", StringComparison.OrdinalIgnoreCase)) + { + value = MonsterValue.FromNumber(Range); + return true; + } + if (token.Equals("hasshield", StringComparison.OrdinalIgnoreCase)) + { + value = MonsterValue.FromBoolean(HasShield); + return true; + } + if (token.Equals("metastate", StringComparison.OrdinalIgnoreCase)) + { + value = MonsterValue.FromText(MetaState); + return true; + } + + // VTank documents setting names as case-sensitive even though the + // built-in monster variables and string comparisons are not. + const string settingPrefix = "setting_"; + if (token.StartsWith(settingPrefix, StringComparison.Ordinal) + && Setting?.Invoke(token[settingPrefix.Length..]) is { } setting) + { + value = setting; + return true; + } + + value = default; + return false; + } +} + +internal sealed class MonsterExpressionException(string message) + : FormatException(message); + +/// +/// Immutable compiled VTank monster-list expression. The lexer deliberately +/// has no quoted strings: VTank strings are runs of letters/spaces and use a +/// backslash to escape every operator, digit, or punctuation character. +/// +internal sealed class MonsterExpression +{ + private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(25); + + private readonly Node _root; + + private MonsterExpression(string source, Node root) + { + Source = source; + _root = root; + } + + public string Source { get; } + public bool IsDynamic => _root.IsDynamic; + + public static MonsterExpression Compile(string source) + { + ArgumentNullException.ThrowIfNull(source); + string normalized = source.Trim(); + if (normalized.Length == 0) + throw new MonsterExpressionException("Monster expression is empty."); + var parser = new Parser(normalized); + Node root = parser.Parse(); + return new MonsterExpression(normalized, root); + } + + public bool TryEvaluate( + in MonsterExpressionContext context, + out MonsterValue value, + out string? error) + { + try + { + value = _root.Evaluate(context); + error = null; + return true; + } + catch (Exception ex) when (ex is MonsterExpressionException + or RegexMatchTimeoutException + or ArgumentException + or OverflowException + or DivideByZeroException) + { + value = default; + error = ex.Message; + return false; + } + } + + public bool IsMatch(in MonsterExpressionContext context, out string? error) + { + if (!TryEvaluate(context, out MonsterValue result, out error)) + return false; + return result.Kind switch + { + MonsterValueKind.Boolean => result.Boolean, + MonsterValueKind.Text => string.Equals( + result.Text.Trim(), + context.Name.Trim(), + StringComparison.OrdinalIgnoreCase), + _ => false, + }; + } + + private abstract class Node(bool isDynamic) + { + internal bool IsDynamic { get; } = isDynamic; + internal abstract MonsterValue Evaluate(in MonsterExpressionContext context); + } + + private sealed class NumberNode(double value) : Node(false) + { + internal override MonsterValue Evaluate(in MonsterExpressionContext context) => + MonsterValue.FromNumber(value); + } + + private sealed class AtomNode(string token) : Node(IsDynamicToken(token)) + { + internal override MonsterValue Evaluate(in MonsterExpressionContext context) => + context.TryResolve(token, out MonsterValue value) + ? value + : MonsterValue.FromText(token.Trim()); + + private static bool IsDynamicToken(string value) => + value.Equals("range", StringComparison.OrdinalIgnoreCase) + || value.Equals("hasshield", StringComparison.OrdinalIgnoreCase) + || value.Equals("metastate", StringComparison.OrdinalIgnoreCase) + || value.StartsWith("setting_", StringComparison.Ordinal); + } + + private sealed class BinaryNode(TokenKind operation, Node left, Node right) + : Node(left.IsDynamic || right.IsDynamic) + { + internal override MonsterValue Evaluate(in MonsterExpressionContext context) + { + // VTank's boolean operators are short-circuiting in practice; this + // also keeps an invalid right branch from poisoning a decided rule. + MonsterValue lhs = left.Evaluate(context); + if (operation == TokenKind.And) + { + bool l = RequireBoolean(lhs, "&&"); + return !l + ? MonsterValue.FromBoolean(false) + : MonsterValue.FromBoolean( + RequireBoolean(right.Evaluate(context), "&&")); + } + if (operation == TokenKind.Or) + { + bool l = RequireBoolean(lhs, "||"); + return l + ? MonsterValue.FromBoolean(true) + : MonsterValue.FromBoolean( + RequireBoolean(right.Evaluate(context), "||")); + } + + MonsterValue rhs = right.Evaluate(context); + return operation switch + { + TokenKind.Modulo => MonsterValue.FromNumber( + (long)RequireNumber(lhs, "%") % (long)RequireNonZero(rhs, "%")), + TokenKind.Divide => MonsterValue.FromNumber( + RequireNumber(lhs, "/") / RequireNonZero(rhs, "/")), + TokenKind.Multiply => MonsterValue.FromNumber( + RequireNumber(lhs, "*") * RequireNumber(rhs, "*")), + TokenKind.Add => Add(lhs, rhs), + TokenKind.Subtract => MonsterValue.FromNumber( + RequireNumber(lhs, "-") - RequireNumber(rhs, "-")), + TokenKind.Regex => RegexMatch(lhs, rhs), + TokenKind.Equal => Compare(lhs, rhs, comparison => comparison == 0), + TokenKind.NotEqual => Compare(lhs, rhs, comparison => comparison != 0), + TokenKind.Greater => Compare(lhs, rhs, comparison => comparison > 0), + TokenKind.Less => Compare(lhs, rhs, comparison => comparison < 0), + TokenKind.GreaterOrEqual => Compare(lhs, rhs, comparison => comparison >= 0), + TokenKind.LessOrEqual => Compare(lhs, rhs, comparison => comparison <= 0), + _ => throw new MonsterExpressionException( + $"Unsupported monster-expression operator {operation}."), + }; + } + + private static MonsterValue Add(MonsterValue left, MonsterValue right) + { + RequireSameType(left, right, "+"); + return left.Kind switch + { + MonsterValueKind.Number => MonsterValue.FromNumber( + left.Number + right.Number), + MonsterValueKind.Text => MonsterValue.FromText( + left.Text + right.Text), + _ => throw TypeError("+", left.Kind), + }; + } + + private static MonsterValue RegexMatch( + MonsterValue left, + MonsterValue right) + { + RequireSameType(left, right, "#"); + if (left.Kind != MonsterValueKind.Text) + throw TypeError("#", left.Kind); + return MonsterValue.FromBoolean(Regex.IsMatch( + left.Text, + right.Text, + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, + RegexTimeout)); + } + + private static MonsterValue Compare( + MonsterValue left, + MonsterValue right, + Func predicate) + { + RequireSameType(left, right, "comparison"); + int comparison = left.Kind switch + { + MonsterValueKind.Number => left.Number.CompareTo(right.Number), + MonsterValueKind.Text => string.Compare( + left.Text, + right.Text, + StringComparison.OrdinalIgnoreCase), + MonsterValueKind.Boolean => left.Boolean.CompareTo(right.Boolean), + _ => throw TypeError("comparison", left.Kind), + }; + return MonsterValue.FromBoolean(predicate(comparison)); + } + + private static void RequireSameType( + MonsterValue left, + MonsterValue right, + string operation) + { + if (left.Kind != right.Kind) + { + throw new MonsterExpressionException( + $"Operator {operation} requires matching operand types; " + + $"received {left.Kind} and {right.Kind}."); + } + } + + private static double RequireNumber(MonsterValue value, string operation) + { + if (value.Kind != MonsterValueKind.Number) + throw TypeError(operation, value.Kind); + return value.Number; + } + + private static double RequireNonZero(MonsterValue value, string operation) + { + double number = RequireNumber(value, operation); + if (number == 0d) + throw new DivideByZeroException($"Operator {operation} divided by zero."); + return number; + } + + private static bool RequireBoolean(MonsterValue value, string operation) + { + if (value.Kind != MonsterValueKind.Boolean) + throw TypeError(operation, value.Kind); + return value.Boolean; + } + + private static MonsterExpressionException TypeError( + string operation, + MonsterValueKind actual) => new( + $"Operator {operation} cannot be applied to {actual}."); + } + + private enum TokenKind + { + End, + Atom, + Number, + LeftParen, + RightParen, + Modulo, + Divide, + Multiply, + Add, + Subtract, + Regex, + NotEqual, + Equal, + Greater, + Less, + GreaterOrEqual, + LessOrEqual, + And, + Or, + } + + private readonly record struct Token(TokenKind Kind, string Text, int Offset); + + private sealed class Lexer(string source) + { + private int _offset; + + internal Token Next() + { + while (_offset < source.Length && char.IsWhiteSpace(source[_offset])) + _offset++; + if (_offset >= source.Length) + return new Token(TokenKind.End, string.Empty, _offset); + + int start = _offset; + char current = source[_offset]; + if (TryOperator(out Token operation)) + return operation; + + if (char.IsDigit(current) + || (current == '.' + && _offset + 1 < source.Length + && char.IsDigit(source[_offset + 1]))) + { + _offset++; + while (_offset < source.Length + && (char.IsDigit(source[_offset]) || source[_offset] == '.')) + { + _offset++; + } + string number = source[start.._offset]; + if (!double.TryParse( + number, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out _)) + { + throw new MonsterExpressionException( + $"Invalid number '{number}' at offset {start}."); + } + return new Token(TokenKind.Number, number, start); + } + + var text = new StringBuilder(); + while (_offset < source.Length) + { + current = source[_offset]; + if (current == '\\') + { + if (_offset + 1 >= source.Length) + { + throw new MonsterExpressionException( + $"Trailing escape at offset {_offset}."); + } + text.Append(source[_offset + 1]); + _offset += 2; + continue; + } + if (IsOperatorStart(current) || char.IsDigit(current)) + break; + text.Append(current); + _offset++; + } + + string atom = text.ToString().Trim(); + if (atom.Length == 0) + { + throw new MonsterExpressionException( + $"Unexpected character '{source[_offset]}' at offset {_offset}; " + + "digits and punctuation in VTank strings must be escaped."); + } + return new Token(TokenKind.Atom, atom, start); + } + + private bool TryOperator(out Token token) + { + int start = _offset; + char c = source[_offset]; + TokenKind kind; + int length = 1; + if (_offset + 1 < source.Length) + { + string pair = source.Substring(_offset, 2); + kind = pair switch + { + "!=" => TokenKind.NotEqual, + "==" => TokenKind.Equal, + ">=" => TokenKind.GreaterOrEqual, + "<=" => TokenKind.LessOrEqual, + "&&" => TokenKind.And, + "||" => TokenKind.Or, + _ => TokenKind.End, + }; + if (kind != TokenKind.End) + { + length = 2; + _offset += length; + token = new Token(kind, pair, start); + return true; + } + } + + kind = c switch + { + '(' => TokenKind.LeftParen, + ')' => TokenKind.RightParen, + '%' => TokenKind.Modulo, + '/' => TokenKind.Divide, + '*' => TokenKind.Multiply, + '+' => TokenKind.Add, + '-' => TokenKind.Subtract, + '#' => TokenKind.Regex, + '>' => TokenKind.Greater, + '<' => TokenKind.Less, + _ => TokenKind.End, + }; + if (kind == TokenKind.End) + { + token = default; + return false; + } + _offset += length; + token = new Token(kind, c.ToString(), start); + return true; + } + + private static bool IsOperatorStart(char value) => + value is '(' or ')' or '%' or '/' or '*' or '+' or '-' or '#' + or '!' or '=' or '>' or '<' or '&' or '|'; + } + + private sealed class Parser + { + private readonly Lexer _lexer; + private Token _current; + + internal Parser(string source) + { + _lexer = new Lexer(source); + _current = _lexer.Next(); + } + + internal Node Parse() + { + Node result = ParseOr(); + if (_current.Kind != TokenKind.End) + { + throw new MonsterExpressionException( + $"Unexpected token '{_current.Text}' at offset {_current.Offset}."); + } + return result; + } + + private Node ParseOr() => ParseLeftAssociative(ParseAnd, TokenKind.Or); + private Node ParseAnd() => ParseLeftAssociative(ParseComparison, TokenKind.And); + + private Node ParseComparison() => ParseLeftAssociative( + ParseRegex, + TokenKind.NotEqual, + TokenKind.Equal, + TokenKind.Greater, + TokenKind.Less, + TokenKind.GreaterOrEqual, + TokenKind.LessOrEqual); + + private Node ParseRegex() => ParseLeftAssociative(ParseSubtract, TokenKind.Regex); + private Node ParseSubtract() => ParseLeftAssociative(ParseAdd, TokenKind.Subtract); + private Node ParseAdd() => ParseLeftAssociative(ParseMultiply, TokenKind.Add); + private Node ParseMultiply() => ParseLeftAssociative(ParseDivide, TokenKind.Multiply); + private Node ParseDivide() => ParseLeftAssociative(ParseModulo, TokenKind.Divide); + private Node ParseModulo() => ParseLeftAssociative(ParsePrimary, TokenKind.Modulo); + + private Node ParseLeftAssociative( + Func operand, + params TokenKind[] operations) + { + Node left = operand(); + while (operations.Contains(_current.Kind)) + { + TokenKind operation = _current.Kind; + Advance(); + left = new BinaryNode(operation, left, operand()); + } + return left; + } + + private Node ParsePrimary() + { + Token token = _current; + switch (token.Kind) + { + case TokenKind.Number: + Advance(); + return new NumberNode(double.Parse( + token.Text, + CultureInfo.InvariantCulture)); + case TokenKind.Atom: + Advance(); + return new AtomNode(token.Text); + case TokenKind.LeftParen: + Advance(); + Node nested = ParseOr(); + Require(TokenKind.RightParen, "Closing ')' expected"); + Advance(); + return nested; + default: + throw new MonsterExpressionException( + $"Operand expected at offset {token.Offset}; found '{token.Text}'."); + } + } + + private void Require(TokenKind kind, string message) + { + if (_current.Kind != kind) + { + throw new MonsterExpressionException( + $"{message} at offset {_current.Offset}."); + } + } + + private void Advance() => _current = _lexer.Next(); + } +} diff --git a/src/AcDream.Plugins.MossTank/MonsterRules.cs b/src/AcDream.Plugins.MossTank/MonsterRules.cs new file mode 100644 index 00000000..942d59ef --- /dev/null +++ b/src/AcDream.Plugins.MossTank/MonsterRules.cs @@ -0,0 +1,150 @@ +namespace AcDream.Plugins.MossTank; + +[Flags] +internal enum MonsterActionFlags +{ + None = 0, + Fester = 1 << 0, + Broadside = 1 << 1, + GravityWell = 1 << 2, + Imperil = 1 << 3, + Yield = 1 << 4, + Vulnerability = 1 << 5, + Attack = 1 << 6, + Ring = 1 << 7, + Streak = 1 << 8, + WeakeningCurse = 1 << 9, + FesteringCurse = 1 << 10, + Corruption = 1 << 11, + DestructiveCurse = 1 << 12, + Corrosion = 1 << 13, +} + +internal enum MonsterDamageType +{ + Auto = 0, + Slash, + Pierce, + Bludgeon, + Cold, + Fire, + Acid, + Electric, + Nether, + VoidBasic, + DrainAuto, + Harm, + None, + PlayerAuto, + Prismatic, + Random, + Fists, + Physical, +} + +/// Every editable column in VTank's Monsters table. +internal sealed record MonsterRuleActions +{ + public MonsterActionFlags Flags { get; init; } = MonsterActionFlags.Attack; + public int Priority { get; init; } + public MonsterDamageType DamageType { get; init; } = MonsterDamageType.Auto; + public MonsterDamageType ExtraVulnerability { get; init; } = + MonsterDamageType.Auto; + public uint WeaponObjectId { get; init; } + public uint OffhandObjectId { get; init; } + /// + /// Durable profile identity. Object ids are session-local, so a loaded + /// profile resolves this exact VTank item name back to the current object. + /// + public string WeaponName { get; init; } = string.Empty; + public string OffhandName { get; init; } = string.Empty; + public MonsterDamageType PetDamageType { get; init; } = + MonsterDamageType.PlayerAuto; + + public int BoundedPriority => Math.Clamp(Priority, -1, 4); + public bool Attacks => (Flags + & (MonsterActionFlags.Attack | MonsterActionFlags.Ring)) != 0; + public bool UsesPrimaryAttack => (Flags & MonsterActionFlags.Attack) != 0; + public bool UsesRing => (Flags & MonsterActionFlags.Ring) != 0; + public bool UsesStreak => (Flags & MonsterActionFlags.Streak) != 0; +} + +/// +/// One ordered VTank Monsters row. Non-default rows compile the exact VTank +/// expression grammar; an expression yielding true, or a text value equal to +/// the monster's name, matches. DEFAULT is considered only after every row. +/// +internal sealed class MonsterRule +{ + private readonly MonsterExpression? _compiled; + + public MonsterRule(string expression, int priority) + : this(expression, new MonsterRuleActions { Priority = priority }) + { + } + + public MonsterRule(string expression, MonsterRuleActions actions) + { + Expression = string.IsNullOrWhiteSpace(expression) + ? "DEFAULT" + : expression.Trim(); + Actions = actions ?? throw new ArgumentNullException(nameof(actions)); + if (!IsDefault) + _compiled = MonsterExpression.Compile(Expression); + } + + public string Expression { get; } + public MonsterRuleActions Actions { get; } + public int Priority => Actions.BoundedPriority; + public bool IsDefault => Expression.Equals( + "DEFAULT", + StringComparison.OrdinalIgnoreCase); + public bool IsDynamic => _compiled?.IsDynamic == true; + + public bool Matches( + in MonsterExpressionContext context, + out string? error) + { + if (_compiled is null) + { + error = null; + return IsDefault; + } + return _compiled.IsMatch(context, out error); + } +} + +internal readonly record struct ResolvedMonsterRule( + MonsterRule Rule, + string? EvaluationError) +{ + public MonsterRuleActions Actions => Rule.Actions; + public int Priority => Rule.Priority; +} + +internal static class MonsterRuleResolver +{ + /// VTank: rows after DEFAULT are checked top-to-bottom; first match wins. + internal static ResolvedMonsterRule Resolve( + IEnumerable rules, + in MonsterExpressionContext context) + { + ArgumentNullException.ThrowIfNull(rules); + MonsterRule? fallback = null; + string? firstError = null; + foreach (MonsterRule rule in rules) + { + if (rule.IsDefault) + { + fallback ??= rule; + continue; + } + if (rule.Matches(context, out string? error)) + return new ResolvedMonsterRule(rule, firstError); + firstError ??= error; + } + + fallback ??= new MonsterRule("DEFAULT", 0); + return new ResolvedMonsterRule(fallback, firstError); + } +} diff --git a/src/AcDream.Plugins.MossTank/MossTankCommands.cs b/src/AcDream.Plugins.MossTank/MossTankCommands.cs new file mode 100644 index 00000000..08a47647 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/MossTankCommands.cs @@ -0,0 +1,1046 @@ +using System.Globalization; +using System.Text; +using AcDream.Plugin.Abstractions; +using AcDream.Plugins.MossTank.Expressions; + +namespace AcDream.Plugins.MossTank; + +/// VTank's documented /vt chat surface. +internal sealed partial class MossTankPanel +{ + private static readonly string[] VtankHelp = + [ + "/vt commands (profiles): settings nav loot meta opt testitem propertydump addnavpt refresh getdb addnavjump addnavcheckpoint", + "/vt commands (actions): start stop forcebuff cancelforcebuff setmetastate fakedeath deletemonster reverseroute reverseroutequery equipitemsfor mexec echo tapjump jump setattackbar", + "/vt commands (game info): dumpspells dumpspecies dumpmats dumpskills", + "/vt commands (debug): log testmonster lockdump dumptracker clearlocks clearbusy listmonstervariables dumpmetavars listmetafunctions metafunchelp fakeimp pscount testspell testpet", + ]; + + private readonly HashSet _commandLogTypes = + new(StringComparer.OrdinalIgnoreCase); + private bool _commandJumpActive; + private bool _commandJumpReleased; + private bool _commandJumpCharging; + private double _commandJumpElapsed; + private double _commandJumpTurnElapsed; + private double _commandJumpChargeSeconds; + private float _commandJumpHeading; + private PluginMovementIntent _commandJumpIntent; + private bool _commandPortalState; + private int _commandPortalCount; + + internal void ExecuteVtankCommand(PluginCommand command) + { + try + { + ExecuteVtankCommandCore(command.Arguments); + } + catch (Exception error) + { + WriteVtank($"Command failed: {error.GetBaseException().Message}"); + _host.Log.Error("MossTank /vt command failed.", error); + } + } + + private void ExecuteVtankCommandCore(string input) + { + (string verb, string arguments) = SplitHead(input); + switch (verb.ToLowerInvariant()) + { + case "": + case "help": + foreach (string line in VtankHelp) + WriteVtank(line); + return; + + case "start": + if (!_combat.Enabled) + SetMacroRunning(true); + else + WriteVtank("Macro is already running."); + return; + case "stop": + if (_combat.Enabled) + SetMacroRunning(false); + if (_running) + Stop("Force buff canceled."); + WriteVtank("Macro stopped."); + return; + case "forcebuff": + if (!_running) + StartOrStop(); + else + WriteVtank("Force buff is already enabled."); + return; + case "cancelforcebuff": + if (_running) + Stop("Force buff canceled."); + WriteVtank("Force buff canceled."); + return; + case "settings": + HandleSettingsCommand(arguments); + return; + case "nav": + HandleRouteProfileCommand(arguments); + return; + case "loot": + HandleLootProfileCommand(arguments); + return; + case "meta": + HandleMetaProfileCommand(arguments); + return; + case "opt": + HandleOptionCommand(arguments); + return; + case "setmetastate": + SetMetaStateFromCommand(arguments); + return; + case "mexec": + ExecuteExpression(arguments); + return; + case "echo": + WriteVtank(arguments); + return; + case "setattackbar": + SetAttackBar(arguments); + return; + case "tapjump": + StartCommandJump( + _host.Automation.Navigation.Snapshot.Position.HeadingDegrees, + shift: false, + milliseconds: 100, + null); + return; + case "jump": + HandleJumpCommand(arguments, addToRoute: false); + return; + case "addnavjump": + HandleJumpCommand(arguments, addToRoute: true); + return; + case "addnavpt": + AddCommandRoutePoint(arguments, checkpoint: false); + return; + case "addnavcheckpoint": + AddCommandRoutePoint(arguments, checkpoint: true); + return; + case "reverseroute": + _navigation.ToggleReverse(); + WriteVtank($"Setting nav backwards to: {_navigation.Reversing}"); + return; + case "reverseroutequery": + WriteVtank($"Nav backwards is: {_navigation.Reversing}"); + return; + case "deletemonster": + DeleteSelectedMonster(); + return; + case "equipitemsfor": + EquipItemsFor(arguments); + return; + case "testitem": + TestSelectedItem(); + return; + case "propertydump": + DumpSelectedProperties(); + return; + case "testmonster": + TestSelectedMonster(); + return; + case "testspell": + TestSpell(arguments); + return; + case "testpet": + TestPet(); + return; + case "listmonstervariables": + WriteVtank("Supported monster expression variables:"); + WriteVtank("true, false, name, typeid, species, maxhp, range, hasshield, metastate, setting_"); + return; + case "dumpmetavars": + DumpMetaVariables(); + return; + case "listmetafunctions": + ListMetaFunctions(); + return; + case "metafunchelp": + MetaFunctionHelp(arguments); + return; + case "fakedeath": + _meta.TriggerFakeDeath(); + WriteVtank("Fake character death trigger fired."); + return; + case "pscount": + WriteVtank($"Portal space toggle count: {_commandPortalCount}"); + return; + case "refresh": + RefreshMonsterEditor(); + RefreshItemEditors(); + RefreshLootEditor(); + RefreshRouteEditor(); + RefreshMetaEditor(); + WriteVtank("Refreshed settings pages."); + return; + case "getdb": + WriteVtank("Game information uses acdream's installed DAT catalog and bundled VTank tables; no remote database download is required."); + return; + case "log": + HandleLogCommand(arguments); + return; + case "lockdump": + WriteVtank($"Action busy: magic={_host.Automation.Magic.IsCasting}, items={_host.Automation.Items.IsBusy}, equipment={_host.Automation.Equipment.IsBusy}"); + return; + case "dumptracker": + DumpObjectTracker(); + return; + case "clearlocks": + ClearMossTankActionLocks(); + WriteVtank("Action locks cleared."); + return; + case "clearbusy": + PluginRecoveryResult recovery = _host.Automation.Recovery + .ClearOneBusyReference(); + WriteVtank(recovery.Accepted + ? $"Action busy: {recovery.PreviousCount} -> {recovery.CurrentCount}." + : recovery.Message); + return; + case "fakeimp": + FakeImperil(); + return; + case "dumpspells": + DumpSpells(); + return; + case "dumpspecies": + DumpSpecies(); + return; + case "dumpmats": + DumpMaterials(); + return; + case "dumpskills": + DumpSkills(); + return; + default: + WriteVtank("Unknown /vt command. Use /vt help."); + return; + } + } + + private void HandleSettingsCommand(string arguments) + { + (string operation, string name) = SplitHead(arguments); + operation = operation.ToLowerInvariant(); + if (name.Length == 0 + || operation is not ("save" or "load" or "savechar" or "loadchar")) + { + WriteVtank("Usage: /vt settings [save/load/savechar/loadchar] [filename]"); + return; + } + name = StripExtension(name, ".usd", ".settings"); + if (operation is "save" or "savechar") + { + if (operation == "savechar") + _profiles.SetMineOnly(true); + if (_profiles.Create( + name, + copyCurrent: true, + _combatSettings, + _buffSettings, + _vitalSettings, + _inventorySettings, + _noBuffItemNames, + out string notice)) + { + ResetProfileConsumers(); + } + WriteVtank(notice); + return; + } + if (!_profiles.Select(name)) + { + WriteVtank($"Settings profile '{name}' was not found."); + return; + } + LoadSelectedProfile(); + WriteVtank($"Loaded settings profile {_profiles.Selected}."); + } + + private void HandleRouteProfileCommand(string arguments) + { + (string operation, string name) = SplitHead(arguments); + operation = operation.ToLowerInvariant(); + if (name.Length == 0 || operation is not ("save" or "load")) + { + WriteVtank("Usage: /vt nav [save/load] [filename]"); + return; + } + name = StripExtension(name, ".nav"); + if (operation == "save") + { + _routeProfiles.Create( + name, + copyCurrent: true, + _navigationSettings, + out string notice); + RefreshRouteEditor(); + WriteVtank(notice); + return; + } + if (!_routeProfiles.Select(name)) + { + if (!_routeProfiles.TryImportLegacy( + name, + _navigationSettings, + _host.Automation.Spells, + out string importNotice)) + { + WriteVtank(importNotice); + return; + } + _navigation.Reset(); + RefreshRouteEditor(); + WriteVtank(importNotice); + return; + } + LoadRouteProfile(); + WriteVtank($"Loaded navigation profile {_routeProfiles.Selected}."); + } + + private void HandleLootProfileCommand(string arguments) + { + (string operation, string name) = SplitHead(arguments); + operation = operation.ToLowerInvariant(); + if (name.Length == 0 || operation is not ("new" or "save" or "load")) + { + WriteVtank("Usage: /vt loot [load/new] [filename]"); + return; + } + name = StripExtension(name, ".utl", ".json"); + if (operation is "new" or "save") + { + _lootProfiles.Create( + name, + copyCurrent: operation == "save", + _inventorySettings.Loot.Rules, + out string notice); + LoadLootProfile(); + WriteVtank(notice); + return; + } + if (!_lootProfiles.Select(name)) + { + if (!_lootProfiles.TryImportLegacy( + name, + _inventorySettings.Loot.Rules, + _inventorySettings.Loot, + out string importNotice)) + { + WriteVtank(importNotice); + return; + } + _loot.Reset(); + RefreshLootEditor(); + WriteVtank(importNotice); + return; + } + LoadLootProfile(); + WriteVtank($"Loaded loot profile {_lootProfiles.Selected}."); + } + + private void HandleMetaProfileCommand(string arguments) + { + (string operation, string name) = SplitHead(arguments); + operation = operation.ToLowerInvariant(); + if (name.Length == 0 || operation is not ("save" or "load")) + { + WriteVtank("Usage: /vt meta [save/load] [filename]"); + return; + } + name = StripExtension(name, ".met", ".json"); + if (operation == "save") + { + _metaProfiles.Create( + name, + copyCurrent: true, + _metaProfile, + out string notice); + LoadMetaProfile(); + WriteVtank(notice); + return; + } + if (!_metaProfiles.Select(name)) + { + if (!_metaProfiles.TryImportLegacy( + name, + out MetaProfile imported, + out string importNotice)) + { + WriteVtank(importNotice); + return; + } + _metaProfile = imported; + _meta.ReplaceProfile(_metaProfile); + if (_initialized) + ApplyPersistedOptionOverrides(); + _selectedMetaRule = 0; + RefreshMetaEditor(); + WriteVtank(importNotice); + return; + } + LoadMetaProfile(); + WriteVtank($"Loaded Meta profile {_metaProfiles.Selected}."); + } + + private void HandleOptionCommand(string arguments) + { + (string operation, string tail) = SplitHead(arguments); + switch (operation.ToLowerInvariant()) + { + case "list": + if (tail.Length != 0) + { + WriteVtank("Usage: /vt opt list"); + return; + } + WriteVtank($"Available options: ({VtankOptionCatalog.Names.Length})"); + for (int index = 0; index < VtankOptionCatalog.Names.Length; index += 4) + WriteVtank(" " + string.Join(" ", VtankOptionCatalog.Names.Skip(index).Take(4))); + return; + case "get": + if (!VtankOptionCatalog.IsKnown(tail)) + { + WriteVtank("Option get: Invalid option specified."); + return; + } + string canonical = VtankOptionCatalog.Canonical(tail); + WriteVtank($"Option {canonical} = {GetMetaOption(canonical).ToDisplayString()}"); + return; + case "set": + case "setinall": + (string name, string rawValue) = SplitHead(tail); + if (!VtankOptionCatalog.IsKnown(name)) + { + WriteVtank("Option set: Invalid option specified."); + return; + } + if (rawValue.Length == 0 || !TryParseOptionValue(rawValue, out ExpressionValue value)) + { + WriteVtank("Option set: Invalid value specified."); + return; + } + canonical = VtankOptionCatalog.Canonical(name); + SetMetaOption(canonical, value); + if (operation.Equals("setinall", StringComparison.OrdinalIgnoreCase)) + { + int count = _profiles.SetOptionInAll( + canonical, + ToMonsterValue(value)); + WriteVtank($"Set option {canonical} in {count} profile(s) = {GetMetaOption(canonical).ToDisplayString()}"); + } + else + { + WriteVtank($"Set option {canonical} = {GetMetaOption(canonical).ToDisplayString()}"); + } + return; + default: + WriteVtank("Usage: /vt opt [list/get/set/setinall]"); + return; + } + } + + private void SetMetaStateFromCommand(string state) + { + if (state.Length == 0) + { + WriteVtank("Usage: /vt setmetastate [somestate]"); + WriteVtank("NOTE: States are case sensitive."); + return; + } + string target = _meta.States.FirstOrDefault(value => + value.Equals(state, StringComparison.Ordinal)) ?? MetaEngine.DefaultState; + if (!target.Equals(state, StringComparison.Ordinal)) + WriteVtank("Warning: Attempted to set an unused state. Setting to default instead."); + _meta.Transition(target); + _combatSettings.MetaState = _meta.CurrentState; + WriteVtank($"Meta state is now {_meta.CurrentState}."); + } + + private void ExecuteExpression(string source) + { + WriteVtank($"MExec evaluating expression: \"{source.Trim()}\""); + try + { + WriteVtank("Result: " + _expressions.Evaluate(source).ToDisplayString()); + } + catch (Exception error) + { + WriteVtank("Expression error: " + error.Message); + } + } + + private void SetAttackBar(string value) + { + if (!float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out float power) + || power is < 0f or > 1f) + { + WriteVtank("Usage: /vt setattackbar [0 to 1]"); + return; + } + _combatSettings.AttackPower = power; + SaveProfile(); + WriteVtank($"Attack bar set to {power.ToString("0.###", CultureInfo.InvariantCulture)}."); + } + + private void HandleJumpCommand(string arguments, bool addToRoute) + { + string[] parts = arguments.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length is < 3 or > 4 + || !float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out float heading) + || !bool.TryParse(parts[1], out bool shift) + || !int.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out int milliseconds) + || milliseconds is < 0 or > 5000 + || !TryParseJumpDirection(parts.Length == 4 ? parts[3] : null, out RouteJumpDirection direction)) + { + WriteVtank(addToRoute + ? "Usage: /vt addnavjump [heading] [shift: true or false] [milliseconds]" + : "Usage: /vt jump [heading] [shift: true or false] [milliseconds]"); + return; + } + if (addToRoute) + { + PluginNavigationPosition position = _host.Automation.Navigation.Snapshot.Position; + _navigationSettings.Waypoints.Add(new RouteWaypoint + { + Type = RouteWaypointType.Jump, + Position = position, + JumpHeadingDegrees = NormalizeHeading(heading), + JumpRun = shift, + JumpChargeMilliseconds = milliseconds, + JumpDirection = direction, + }); + SaveRouteProfile(); + RefreshRouteEditor(); + WriteVtank("Added jump to the current route."); + return; + } + StartCommandJump(heading, shift, milliseconds, direction); + } + + private void StartCommandJump( + float heading, + bool shift, + int milliseconds, + RouteJumpDirection? direction) + { + PluginNavigationSnapshot snapshot = _host.Automation.Navigation.Snapshot; + if (!snapshot.IsAvailable || snapshot.IsPortalSpace) + { + WriteVtank("Jump unavailable outside the world."); + return; + } + RouteJumpDirection resolved = direction ?? RouteJumpDirection.Forward; + _commandJumpHeading = NormalizeHeading(heading); + _commandJumpIntent = new PluginMovementIntent( + Forward: resolved == RouteJumpDirection.Forward, + StrafeLeft: resolved == RouteJumpDirection.StrafeLeft, + StrafeRight: resolved == RouteJumpDirection.StrafeRight, + Run: shift, + Jump: true); + _commandJumpChargeSeconds = Math.Clamp(milliseconds / 1000d, 0.05d, 5d); + _commandJumpElapsed = 0d; + _commandJumpTurnElapsed = 0d; + _commandJumpReleased = false; + _commandJumpCharging = false; + _commandJumpActive = true; + WriteVtank($"Turning to heading {_commandJumpHeading:0.#} for jump."); + } + + private bool TickCommandJump(double elapsedSeconds) + { + if (!_commandJumpActive) + return false; + + INavigationAutomation navigation = _host.Automation.Navigation; + PluginNavigationSnapshot snapshot = navigation.Snapshot; + if (!snapshot.IsAvailable || snapshot.IsPortalSpace) + { + navigation.ClearMovementIntent(); + _commandJumpActive = false; + WriteVtank("Jump canceled because the character left the world."); + return false; + } + + if (!_commandJumpCharging) + { + _commandJumpTurnElapsed += Math.Max(0d, elapsedSeconds); + float delta = NavigationController.SignedHeadingDelta( + snapshot.Position.HeadingDegrees, + _commandJumpHeading); + if (Math.Abs(delta) > 4f) + { + if (_commandJumpTurnElapsed > 10d + || navigation.SetMovementIntent(new PluginMovementIntent( + TurnLeft: delta < 0f, + TurnRight: delta > 0f, + Run: _commandJumpIntent.Run)) + != PluginNavigationCommandStatus.Accepted) + { + navigation.ClearMovementIntent(); + _commandJumpActive = false; + WriteVtank("Jump command could not align to the requested heading."); + return false; + } + return true; + } + + _commandJumpCharging = navigation.SetMovementIntent(_commandJumpIntent) + == PluginNavigationCommandStatus.Accepted; + if (!_commandJumpCharging) + { + navigation.ClearMovementIntent(); + _commandJumpActive = false; + WriteVtank("Jump command was refused by the host."); + return false; + } + _commandJumpElapsed = 0d; + WriteVtank($"Jump charging at heading {_commandJumpHeading:0.#}."); + } + + _commandJumpElapsed += Math.Max(0d, elapsedSeconds); + if (!_commandJumpReleased && _commandJumpElapsed >= _commandJumpChargeSeconds) + { + _commandJumpReleased = true; + navigation.SetMovementIntent(_commandJumpIntent with { Jump = false }); + } + if (_commandJumpElapsed < _commandJumpChargeSeconds + 0.25d) + return true; + navigation.ClearMovementIntent(); + _commandJumpActive = false; + _commandJumpCharging = false; + return false; + } + + private void AddCommandRoutePoint(string coordinates, bool checkpoint) + { + PluginNavigationPosition position; + if (coordinates.Length == 0) + { + position = _host.Automation.Navigation.Snapshot.Position; + } + else if (!TryParseCoordinates(coordinates, out position)) + { + WriteVtank(checkpoint + ? "Usage: /vt addnavcheckpoint [coords] OR /vt addnavcheckpoint" + : "Usage: /vt addnavpt [coords] OR /vt addnavpt"); + return; + } + _navigationSettings.Waypoints.Add(new RouteWaypoint + { + Type = checkpoint ? RouteWaypointType.Checkpoint : RouteWaypointType.Point, + Position = position, + }); + SaveRouteProfile(); + RefreshRouteEditor(); + WriteVtank(checkpoint ? "Added navigation checkpoint." : "Added navigation point."); + } + + private void DeleteSelectedMonster() + { + uint selected = _host.Selection.SelectedObjectId ?? 0u; + PluginCombatTarget target = _host.Automation.Combat + .CaptureHostileTargets(float.MaxValue) + .FirstOrDefault(value => value.ObjectId == selected); + if (target.ObjectId == 0u) + { + WriteVtank("Select a monster, then do /vt deletemonster"); + return; + } + PluginCombatCommandResult result = + _host.Automation.Combat.DismissGhostTarget(selected); + WriteVtank(result.Accepted + ? $"Forcing the client to delete {target.Name} ({target.ObjectId})!!" + : $"Unable to delete {target.Name}: {result.Status}"); + } + + private void EquipItemsFor(string monsterName) + { + if (monsterName.Length == 0) + { + WriteVtank("Usage: /vt equipitemsfor [monster name]"); + WriteVtank("NOTE: each use of this command invokes one equipment step; multiple calls may be required."); + return; + } + bool ready = _combat.EquipOneStepForMonster(monsterName); + WriteVtank($"Changing items for monster \"{monsterName}\", ready: {ready}"); + } + + private void TestSelectedItem() + { + if (!TryGetSelectedInventoryItem(out PluginInventoryItem item)) + { + WriteVtank("TestItem: No item selected."); + return; + } + if (!_host.Automation.Items.TryCaptureProperties(item.ObjectId, out PluginItemProperties properties)) + { + _host.Automation.Objects.Identify(item.ObjectId); + WriteVtank("TestItem: Waiting for appraisal data."); + return; + } + LootDecision? decision = LootRuleEngine.Decide( + item, + properties, + _inventorySettings.Loot.Rules, + _host.Automation.Items.CaptureOwnedItems(), + host: _host); + WriteVtank(decision is { } match + ? $"TestItem: {item.Name} => {match.Action} ({match.RuleName}, priority {match.Priority})." + : $"TestItem: {item.Name} => NoLoot."); + } + + private void DumpSelectedProperties() + { + uint selected = _host.Selection.SelectedObjectId ?? 0u; + if (selected == 0u + || !_host.Automation.Objects.TryGet(selected, out PluginWorldObject item) + || !_host.Automation.Objects.TryCaptureProperties(selected, out PluginItemProperties properties)) + { + WriteVtank("Propertydump: Either no object selected or current selection object is invalid or not appraised."); + return; + } + WriteVtank($"Object 0x{item.ObjectId:X8}: {item.Name}, class={(int)item.ObjectClass}, WCID={item.WeenieClassId}"); + DumpPropertyTable("Int", properties.Ints); + DumpPropertyTable("Int64", properties.Int64s); + DumpPropertyTable("Bool", properties.Bools); + DumpPropertyTable("Float", properties.Floats); + DumpPropertyTable("String", properties.Strings); + DumpPropertyTable("DataId", properties.DataIds); + DumpPropertyTable("InstanceId", properties.InstanceIds); + } + + private void TestSelectedMonster() + { + uint selected = _host.Selection.SelectedObjectId ?? 0u; + PluginCombatTarget target = _host.Automation.Combat + .CaptureHostileTargets(float.MaxValue) + .FirstOrDefault(value => value.ObjectId == selected); + if (target.ObjectId == 0u) + { + WriteVtank("TestMonster: No monster selected."); + return; + } + ResolvedMonsterRule resolved = _combatSettings.ResolveRule(target); + WriteVtank($"TestMonster: evaluating monster rules for monster {target.Name}, type {target.WeenieClassId}"); + WriteVtank($"Matched '{resolved.Rule.Expression}', priority {resolved.Priority}, damage {resolved.Actions.DamageType}, attack={resolved.Actions.UsesPrimaryAttack}."); + if (resolved.EvaluationError is { Length: > 0 } error) + WriteVtank("Expression warning: " + error); + } + + private void TestSpell(string argument) + { + if (!uint.TryParse(argument, NumberStyles.Integer, CultureInfo.InvariantCulture, out uint spellId)) + { + WriteVtank("Usage: /vt testspell [spellid]"); + return; + } + if (!_host.Automation.Spells.TryGet(spellId, out PluginSpellInfo spell)) + { + WriteVtank("Invalid spellid."); + return; + } + WriteVtank("---------------------------------"); + WriteVtank($"Testing ability to cast spell {spell.Name}, family {spell.Family}, quality {spell.Quality}, diff {spell.Difficulty}"); + WriteVtank($"Known: {_host.Automation.Spells.IsKnown(spellId)}, cast gate: {_host.Automation.Magic.EvaluateGate(spellId)}"); + WriteVtank("---------------------------------"); + } + + private void TestPet() + { + IReadOnlyList targets = _host.Automation.Combat + .CaptureHostileTargets(_combatSettings.PetRangeMode == PetRangeMode.Custom + ? _combatSettings.PetCustomRange + : _combatSettings.MaximumRange); + PetAutomationChoice choice = PetAutomation.Select( + _host.Automation.Items.CaptureOwnedItems(), + targets, + _host.Automation.Character, + _combatSettings, + _host.Automation.Items.ActiveOwnedPetCount, + allowRefill: true, + allowSummon: true); + WriteVtank(choice.Kind == PetAutomationActionKind.None + ? "Pet can spawn: False" + : $"Pet can spawn: True, action: {choice.Kind}, device: {choice.Device.Name}"); + } + + private void DumpMetaVariables() + { + WriteVtank("Assigned meta variables:"); + foreach (ExpressionVariableScope scope in Enum.GetValues()) + { + foreach ((string name, ExpressionValue value) in _expressions.State.Capture(scope)) + WriteVtank($"{scope}.{name} = {value.ToDisplayString()}"); + } + } + + private void ListMetaFunctions() + { + WriteVtank("Available builtin meta functions:"); + WriteChunks(_expressions.Functions + .OrderBy(static function => function.Name, StringComparer.OrdinalIgnoreCase) + .Select(static function => function.Name)); + } + + private void MetaFunctionHelp(string name) + { + ExpressionFunction? function = _expressions.Functions.FirstOrDefault(value => + value.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (function is null) + { + WriteVtank($"Function not defined \"{name}\""); + return; + } + WriteVtank("-------------------------------"); + WriteVtank("Function: " + function.Name); + WriteVtank("Signature: " + function.Signature); + WriteVtank("Description: " + function.Description); + string count = function.MinimumArguments == function.MaximumArguments + ? function.MinimumArguments.ToString(CultureInfo.InvariantCulture) + : $"{function.MinimumArguments}..{function.MaximumArguments}"; + WriteVtank("Parameter count: " + count); + WriteVtank("-------------------------------"); + } + + private void HandleLogCommand(string arguments) + { + string[] parts = arguments.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) + { + WriteVtank(_commandLogTypes.Count == 0 + ? "Not currently logging." + : "Log state: " + string.Join(' ', _commandLogTypes.Order(StringComparer.OrdinalIgnoreCase))); + WriteVtank("Valid logtypes: ActiveRule SalvageList SpellCast RuleInfo Timers CastInfo DebuffChoice Loot CharProps Misc BusyState"); + return; + } + if (parts.Length == 2) + parts[1] = parts[1].ToLowerInvariant(); + if (parts.Length != 2 || parts[1] is not ("on" or "off")) + { + WriteVtank("Usage: /vt log [type] [on/off]"); + return; + } + string type = parts[0]; + if (parts[1] == "on") + _commandLogTypes.Add(type); + else + _commandLogTypes.Remove(type); + WriteVtank((parts[1] == "on" ? "Set " : "Reset ") + type); + } + + private void DumpObjectTracker() + { + IReadOnlyList objects = _host.Automation.Objects.CaptureObjects(); + WriteVtank($"Object tracker count: {objects.Count}"); + foreach (PluginWorldObject item in objects.Take(100)) + WriteVtank($"0x{item.ObjectId:X8} {item.ObjectClass} {item.Name}"); + if (objects.Count > 100) + WriteVtank($"... {objects.Count - 100} more objects omitted from chat."); + } + + private void DumpSpells() + { + PluginSpellInfo[] spells = _host.Automation.Spells.KnownCombatSpells + .Concat(_host.Automation.Spells.KnownSelfBuffs) + .GroupBy(static spell => spell.SpellId) + .Select(static group => group.First()) + .OrderBy(static spell => spell.SpellId) + .ToArray(); + WriteVtank($"Known spell table ({spells.Length}):"); + foreach (PluginSpellInfo spell in spells) + WriteVtank($"{spell.SpellId}\t{spell.Name}\t{spell.Family}\t{spell.Difficulty}"); + } + + private void DumpSpecies() + { + var species = _host.Automation.Combat.CaptureHostileTargets(float.MaxValue) + .Where(static target => target.SpeciesId != 0) + .GroupBy(static target => target.SpeciesId) + .Select(static group => (Id: group.Key, Name: group.First().SpeciesName)) + .OrderBy(static value => value.Id) + .ToArray(); + WriteVtank($"Currently observed species ({species.Length}):"); + foreach (var entry in species) + WriteVtank($"{entry.Id}\t{entry.Name}"); + } + + private void DumpMaterials() + { + var materials = _host.Automation.Items.CaptureOwnedItems() + .Where(static item => item.MaterialType != 0u) + .GroupBy(static item => item.MaterialType) + .OrderBy(static group => group.Key); + WriteVtank("Materials present in owned inventory:"); + foreach (IGrouping group in materials) + WriteVtank($"{group.Key}\t{group.First().Name}"); + } + + private void DumpSkills() + { + WriteVtank($"Character skills ({_host.Automation.Character.Skills.Count}):"); + foreach (PluginSkillInfo skill in _host.Automation.Character.Skills.OrderBy(static value => value.SkillId)) + WriteVtank($"{skill.SkillId}\t{skill.Name}\t{skill.Base}\t{skill.Current}\t{skill.Training}"); + } + + private void ObserveCommandPortalState() + { + bool current = _host.Automation.Navigation.Snapshot.IsPortalSpace; + if (current != _commandPortalState) + { + _commandPortalState = current; + _commandPortalCount++; + } + } + + private void ResetCommandSession() + { + if (_commandJumpActive || _commandJumpCharging) + _host.Automation.Navigation.ClearMovementIntent(); + _commandJumpActive = false; + _commandJumpReleased = false; + _commandJumpCharging = false; + _commandJumpElapsed = 0d; + _commandJumpTurnElapsed = 0d; + _commandJumpChargeSeconds = 0d; + _commandJumpHeading = 0f; + _commandJumpIntent = default; + _commandPortalState = _host.Automation.Navigation.Snapshot.IsPortalSpace; + _commandPortalCount = 0; + } + + private void WriteVtank(string text) => + _host.Automation.Chat.PostSystemMessage(text); + + private void WriteChunks(IEnumerable values) + { + var line = new StringBuilder(); + foreach (string value in values) + { + int extra = line.Length == 0 ? value.Length : value.Length + 2; + if (line.Length != 0 && line.Length + extra > 240) + { + WriteVtank(line.ToString()); + line.Clear(); + } + if (line.Length != 0) + line.Append(", "); + line.Append(value); + } + if (line.Length != 0) + WriteVtank(line.ToString()); + } + + private void DumpPropertyTable(string kind, IReadOnlyDictionary values) + { + foreach ((uint key, T value) in values.OrderBy(static pair => pair.Key)) + WriteVtank($"{kind}[{key}] = {value}"); + } + + private static (string Head, string Tail) SplitHead(string value) + { + string trimmed = value.Trim(); + int separator = trimmed.IndexOfAny([' ', '\t']); + return separator < 0 + ? (trimmed, string.Empty) + : (trimmed[..separator], trimmed[(separator + 1)..].Trim()); + } + + private static string StripExtension(string name, params string[] extensions) + { + string result = name.Trim(); + foreach (string extension in extensions) + { + if (result.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) + return result[..^extension.Length]; + } + return result; + } + + private static bool TryParseOptionValue(string source, out ExpressionValue value) + { + if (bool.TryParse(source, out bool boolean)) + { + value = ExpressionValue.Boolean(boolean); + return true; + } + if (double.TryParse(source, NumberStyles.Float, CultureInfo.InvariantCulture, out double number)) + { + value = ExpressionValue.Number(number); + return true; + } + value = ExpressionValue.String(source); + return source.Length != 0; + } + + private bool TryParseCoordinates(string source, out PluginNavigationPosition position) + { + position = default; + string[] parts = source.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2 + || !TryParseCompass(parts[0], northSouth: true, out double northSouth) + || !TryParseCompass(parts[1], northSouth: false, out double eastWest)) + { + return false; + } + double elevation = 0d; + if (parts.Length >= 3 + && !double.TryParse(parts[2], NumberStyles.Float, CultureInfo.InvariantCulture, out elevation)) + { + return false; + } + PluginNavigationPosition current = _host.Automation.Navigation.Snapshot.Position; + position = new PluginNavigationPosition( + current.CellId, + eastWest, + northSouth, + elevation, + current.HeadingDegrees, + IsOutdoor: true); + return true; + } + + private static bool TryParseCompass(string source, bool northSouth, out double value) + { + value = 0d; + string trimmed = source.Trim(); + if (trimmed.Length < 2) + return false; + char direction = char.ToUpperInvariant(trimmed[^1]); + if (northSouth ? direction is not ('N' or 'S') : direction is not ('E' or 'W')) + return false; + if (!double.TryParse(trimmed[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out double magnitude)) + return false; + value = direction is 'S' or 'W' ? -Math.Abs(magnitude) : Math.Abs(magnitude); + return true; + } + + private static bool TryParseJumpDirection(string? value, out RouteJumpDirection direction) + { + direction = RouteJumpDirection.Forward; + if (string.IsNullOrWhiteSpace(value) || value.Equals("forward", StringComparison.OrdinalIgnoreCase)) + return true; + if (value.Equals("strafeleft", StringComparison.OrdinalIgnoreCase)) + { + direction = RouteJumpDirection.StrafeLeft; + return true; + } + if (value.Equals("straferight", StringComparison.OrdinalIgnoreCase)) + { + direction = RouteJumpDirection.StrafeRight; + return true; + } + return false; + } + + private static float NormalizeHeading(float heading) + { + float result = heading % 360f; + return result < 0f ? result + 360f : result; + } +} diff --git a/src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs new file mode 100644 index 00000000..620f3944 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs @@ -0,0 +1,477 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// Independent VTank loot-profile lifecycle. Macro settings select this +/// profile by character, but its ordered rules live in their own document. +/// +internal sealed class MossTankLootProfileStore +{ + public const string ByCharacter = "By char"; + private const string IndexKey = "profiles/loot/index.json"; + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + }; + + private readonly IPluginHost _host; + private IndexDocument _index; + private string _characterName = string.Empty; + private string _selected = ByCharacter; + + public MossTankLootProfileStore(IPluginHost host) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _index = Read(IndexKey) ?? new IndexDocument(); + _index.Names ??= []; + _index.SelectedByCharacter = new Dictionary( + _index.SelectedByCharacter ?? new Dictionary(), + StringComparer.OrdinalIgnoreCase); + } + + public string Selected => _selected; + public string? RecoveryNotice { get; private set; } + public IReadOnlyList AvailableNames => new[] { ByCharacter } + .Concat(_index.Names) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(name => name.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .ThenBy(static name => name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + public bool BindCharacter(string? characterName) + { + string normalized = string.IsNullOrWhiteSpace(characterName) + ? string.Empty + : characterName.Trim(); + if (string.Equals( + normalized, + _characterName, + StringComparison.OrdinalIgnoreCase)) + { + return false; + } + _characterName = normalized; + _selected = _index.SelectedByCharacter.TryGetValue( + SelectionKey(), + out string? selected) + && IsKnown(selected) + ? CanonicalName(selected) + : ByCharacter; + return true; + } + + public bool Select(string? name) + { + string normalized = name?.Trim() ?? string.Empty; + if (!IsKnown(normalized)) + return false; + _selected = CanonicalName(normalized); + _index.SelectedByCharacter[SelectionKey()] = _selected; + SaveIndex(); + return true; + } + + public bool Create( + string? name, + bool copyCurrent, + IReadOnlyList current, + out string notice, + LootSettings? settings = null) + { + string normalized = name?.Trim() ?? string.Empty; + if (normalized.Length is < 1 or > 64) + { + notice = "Enter a loot profile name (1-64 characters)."; + return false; + } + if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + { + notice = "'By char' is the built-in loot profile."; + return false; + } + + LootProfileDocument? currentDocument = copyCurrent + ? Read(CurrentKey()) + : null; + var document = new LootProfileDocument + { + Rules = copyCurrent + ? current.Select(LootRuleDocument.From).ToArray() + : [], + SalvageCombine = copyCurrent + ? (settings?.SalvageCombine.Clone() + ?? currentDocument?.SalvageCombine?.Clone() + ?? new VtankSalvageCombineSettings()) + : new VtankSalvageCombineSettings(), + UnknownBlocks = copyCurrent + ? currentDocument?.UnknownBlocks ?? [] + : [], + }; + Write(ProfileKey(normalized, byCharacter: false), document); + if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase)) + _index.Names.Add(normalized); + _selected = _index.Names.First(entry => entry.Equals( + normalized, + StringComparison.OrdinalIgnoreCase)); + _index.SelectedByCharacter[SelectionKey()] = _selected; + SaveIndex(); + WriteLegacyExport(_selected, document); + notice = copyCurrent + ? $"Copied loot rules to {_selected}." + : $"Created loot profile {_selected}."; + return true; + } + + /// Returns false when no document exists (legacy migration seam). + public bool LoadCurrent(List target, LootSettings? settings = null) + { + ArgumentNullException.ThrowIfNull(target); + LootProfileDocument? document = Read(CurrentKey()); + if (document is null) + return false; + target.Clear(); + foreach (LootRuleDocument rule in document.Rules ?? []) + target.Add(rule.ToRule()); + if (settings is not null) + { + settings.SalvageCombine = + document.SalvageCombine?.Clone() + ?? new VtankSalvageCombineSettings(); + } + return true; + } + + /// + /// Loads a profile for an automation job without changing the profile + /// selected in the MossTank editor. UtilityBelt's item giver has the same + /// separation: using a give profile must not replace the active loot + /// profile. + /// + public bool TryLoadNamed(string? name, List target) + { + ArgumentNullException.ThrowIfNull(target); + string normalized = name?.Trim() ?? string.Empty; + if (normalized.EndsWith(".utl", StringComparison.OrdinalIgnoreCase)) + normalized = normalized[..^4]; + if (!IsKnown(normalized)) + return false; + + string canonical = CanonicalName(normalized); + string key = canonical.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) + ? ProfileKey(_characterName, byCharacter: true) + : ProfileKey(canonical, byCharacter: false); + LootProfileDocument? document = Read(key); + if (document is null) + return false; + + target.Clear(); + foreach (LootRuleDocument rule in document.Rules ?? []) + target.Add(rule.ToRule()); + return true; + } + + public void SaveCurrent( + IReadOnlyList rules, + LootSettings? settings = null) + { + LootProfileDocument? existing = Read(CurrentKey()); + var document = new LootProfileDocument + { + Rules = rules.Select(LootRuleDocument.From).ToArray(), + SalvageCombine = settings?.SalvageCombine.Clone() + ?? existing?.SalvageCombine?.Clone() + ?? new VtankSalvageCombineSettings(), + UnknownBlocks = existing?.UnknownBlocks ?? [], + }; + Write(CurrentKey(), document); + WriteLegacyExport(LegacyProfileName(), document); + } + + public void ClearCurrent(List target, LootSettings? settings = null) + { + target.Clear(); + if (settings is not null) + settings.SalvageCombine = new VtankSalvageCombineSettings(); + SaveCurrent(target, settings); + } + + public bool TryImportLegacy( + string? name, + List target, + LootSettings? settings, + out string notice) + { + ArgumentNullException.ThrowIfNull(target); + string normalized = name?.Trim() ?? string.Empty; + if (normalized.EndsWith(".utl", StringComparison.OrdinalIgnoreCase)) + normalized = normalized[..^4]; + if (!_host.Storage.IsAvailable || normalized.Length == 0) + { + notice = "Legacy loot-profile storage is unavailable."; + return false; + } + string? key = _host.Storage.List("imports") + .Concat(_host.Storage.List("exports")) + .FirstOrDefault(candidate => + candidate.EndsWith(".utl", StringComparison.OrdinalIgnoreCase) + && Path.GetFileNameWithoutExtension(candidate).Equals( + normalized, + StringComparison.OrdinalIgnoreCase)); + string? source = key is null ? null : _host.Storage.ReadText(key); + if (string.IsNullOrWhiteSpace(source)) + { + notice = $"VTClassic loot file '{normalized}.utl' was not found in imports."; + return false; + } + if (!VtankLootProfileSerializer.TryRead( + source, + out VtankLootProfile imported, + out string error)) + { + notice = $"Could not import {Path.GetFileName(key)}: {error}"; + return false; + } + + var document = new LootProfileDocument + { + Rules = imported.Rules.Select(LootRuleDocument.From).ToArray(), + SalvageCombine = imported.SalvageCombine.Clone(), + UnknownBlocks = imported.UnknownBlocks.Select( + VtankLootExtraBlockDocument.From).ToArray(), + }; + Write(ProfileKey(normalized, byCharacter: false), document); + if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase)) + _index.Names.Add(normalized); + _selected = _index.Names.First(entry => entry.Equals( + normalized, + StringComparison.OrdinalIgnoreCase)); + _index.SelectedByCharacter[SelectionKey()] = _selected; + SaveIndex(); + target.Clear(); + target.AddRange(imported.Rules); + if (settings is not null) + settings.SalvageCombine = imported.SalvageCombine.Clone(); + WriteLegacyExport(_selected, document); + notice = $"Imported VTClassic loot profile {_selected}."; + return true; + } + + private bool IsKnown(string? name) => name is not null + && (name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) + || _index.Names.Contains(name, StringComparer.OrdinalIgnoreCase)); + + private string CanonicalName(string name) => name.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) + ? ByCharacter + : _index.Names.First(entry => entry.Equals( + name, + StringComparison.OrdinalIgnoreCase)); + + private string CurrentKey() => _selected.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) + ? ProfileKey(_characterName, byCharacter: true) + : ProfileKey(_selected, byCharacter: false); + + private static string ProfileKey(string value, bool byCharacter) + { + string identity = (byCharacter ? "char:" : "named:") + + value.Trim().ToUpperInvariant(); + string hash = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(identity))); + return $"profiles/loot/{hash}.json"; + } + + private string SelectionKey() => string.IsNullOrWhiteSpace(_characterName) + ? "_default" + : _characterName; + + private T? Read(string key) where T : class + { + if (!_host.Storage.IsAvailable) + return null; + string? json = null; + try + { + json = _host.Storage.ReadText(key); + return string.IsNullOrWhiteSpace(json) + ? null + : JsonSerializer.Deserialize(json, Options); + } + catch (Exception error) + { + RecoveryNotice = MossTankProfileRecovery.Preserve( + _host, + "loot", + key, + json, + error); + _host.Log.Warn(RecoveryNotice); + return null; + } + } + + private void Write(string key, T document) + { + if (!_host.Storage.IsAvailable) + return; + try + { + _host.Storage.WriteText(key, JsonSerializer.Serialize(document, Options)); + } + catch (Exception error) + { + _host.Log.Warn($"MossTank loot profile could not be saved: {error.Message}"); + } + } + + private void SaveIndex() => Write(IndexKey, _index); + + private void WriteLegacyExport(string name, LootProfileDocument document) + { + if (!_host.Storage.IsAvailable) + return; + try + { + _host.Storage.WriteText( + $"exports/{LegacyFileName(name)}.utl", + VtankLootProfileSerializer.Write(document.ToVtankProfile())); + } + catch (Exception error) + { + _host.Log.Warn( + $"MossTank VTClassic loot export could not be saved: {error.Message}"); + } + } + + private string LegacyProfileName() => _selected.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) + ? string.IsNullOrWhiteSpace(_characterName) + ? ByCharacter + : _characterName + : _selected; + + private static string LegacyFileName(string name) + { + char[] invalid = Path.GetInvalidFileNameChars(); + var result = new StringBuilder(name.Length); + foreach (char value in name.Trim()) + { + result.Append(value is '/' or '\\' || invalid.Contains(value) + ? '_' + : value); + } + return result.Length == 0 ? "Loot" : result.ToString(); + } + + private sealed class IndexDocument + { + public int Version { get; set; } = 1; + public List Names { get; set; } = []; + public Dictionary SelectedByCharacter { get; set; } = + new(StringComparer.OrdinalIgnoreCase); + } + + private sealed class LootProfileDocument + { + public int Version { get; set; } = 2; + public LootRuleDocument[] Rules { get; set; } = []; + public VtankSalvageCombineSettings? SalvageCombine { get; set; } = new(); + public VtankLootExtraBlockDocument[] UnknownBlocks { get; set; } = []; + + public VtankLootProfile ToVtankProfile() => new() + { + Rules = (Rules ?? []).Select(static rule => rule.ToRule()).ToList(), + SalvageCombine = SalvageCombine?.Clone() + ?? new VtankSalvageCombineSettings(), + UnknownBlocks = (UnknownBlocks ?? []) + .Select(static block => block.ToBlock()) + .ToList(), + }; + } + + private sealed class LootRuleDocument + { + public string Name { get; set; } = "Rule"; + public string Expression { get; set; } = "*"; + public LootAction Action { get; set; } = LootAction.Keep; + public int KeepCount { get; set; } = 1; + public int Priority { get; set; } + public string CustomExpression { get; set; } = string.Empty; + public VtankLootRequirementDocument[] Requirements { get; set; } = []; + + public static LootRuleDocument From(LootRule rule) => new() + { + Name = rule.Name, + Expression = rule.Expression, + Action = rule.Action, + KeepCount = rule.KeepCount, + Priority = rule.Priority, + CustomExpression = rule.CustomExpression, + Requirements = rule.VtankRequirements.Select( + VtankLootRequirementDocument.From).ToArray(), + }; + + public LootRule ToRule() => new() + { + Name = string.IsNullOrWhiteSpace(Name) ? "Rule" : Name.Trim(), + Expression = string.IsNullOrWhiteSpace(Expression) + ? "*" + : Expression.Trim(), + Action = Action, + KeepCount = Math.Clamp(KeepCount, 0, 100000), + Priority = Math.Clamp(Priority, -1000, 1000), + CustomExpression = CustomExpression ?? string.Empty, + VtankRequirements = (Requirements ?? []) + .Select(static requirement => requirement.ToRequirement()) + .ToList(), + }; + } + + private sealed class VtankLootRequirementDocument + { + public int Type { get; set; } + public string Payload { get; set; } = string.Empty; + + public static VtankLootRequirementDocument From( + VtankLootRequirement requirement) => new() + { + Type = requirement.Type, + Payload = requirement.Payload, + }; + + public VtankLootRequirement ToRequirement() => new() + { + Type = Type, + Payload = Payload ?? string.Empty, + }; + } + + private sealed class VtankLootExtraBlockDocument + { + public string Type { get; set; } = string.Empty; + public string Payload { get; set; } = string.Empty; + + public static VtankLootExtraBlockDocument From( + VtankLootExtraBlock block) => new() + { + Type = block.Type, + Payload = block.Payload, + }; + + public VtankLootExtraBlock ToBlock() => new() + { + Type = Type ?? string.Empty, + Payload = Payload ?? string.Empty, + }; + } +} diff --git a/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs new file mode 100644 index 00000000..35eecd04 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs @@ -0,0 +1,286 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// Independent VTank-style By-char/named Meta profile lifetime. +internal sealed class MossTankMetaProfileStore +{ + public const string ByCharacter = "By char"; + private const string IndexKey = "profiles/meta/index.json"; + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + }; + + private readonly IPluginHost _host; + private IndexDocument _index; + private string _character = string.Empty; + private string _selected = ByCharacter; + + public MossTankMetaProfileStore(IPluginHost host) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _index = Read(IndexKey) ?? new IndexDocument(); + _index.Names ??= []; + _index.SelectedByCharacter = new Dictionary( + _index.SelectedByCharacter ?? [], + StringComparer.OrdinalIgnoreCase); + } + + private List Names => _index.Names ??= []; + + private Dictionary SelectedByCharacter => + _index.SelectedByCharacter ??= new Dictionary( + StringComparer.OrdinalIgnoreCase); + + public string Selected => _selected; + public string? RecoveryNotice { get; private set; } + public IReadOnlyList AvailableNames => new[] { ByCharacter } + .Concat(Names) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(static name => name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) + ? 0 : 1) + .ThenBy(static name => name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + public bool BindCharacter(string? characterName) + { + string normalized = string.IsNullOrWhiteSpace(characterName) + ? string.Empty + : characterName.Trim(); + if (normalized.Equals(_character, StringComparison.OrdinalIgnoreCase)) + return false; + _character = normalized; + _selected = SelectedByCharacter.TryGetValue( + CharacterKey(), + out string? selected) + && IsKnown(selected) + ? Canonical(selected) + : ByCharacter; + return true; + } + + public MetaProfile LoadCurrent() => + Read(CurrentKey()) ?? new MetaProfile(); + + public void SaveCurrent(MetaProfile profile) + { + Write(CurrentKey(), profile); + WriteLegacyExport(LegacyProfileName(), profile); + } + + public bool Select(string? name) + { + string normalized = Normalize(name); + if (!IsKnown(normalized)) + return false; + _selected = Canonical(normalized); + SelectedByCharacter[CharacterKey()] = _selected; + SaveIndex(); + return true; + } + + public bool Create( + string? name, + bool copyCurrent, + MetaProfile current, + out string notice) + { + string normalized = Normalize(name); + if (normalized.Length is < 1 or > 64 + || normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + { + notice = "Enter a unique Meta profile name (1-64 characters)."; + return false; + } + MetaProfile document = copyCurrent + ? Clone(current) + : new MetaProfile(); + Write(NamedKey(normalized), document); + if (!Names.Contains(normalized, StringComparer.OrdinalIgnoreCase)) + Names.Add(normalized); + _selected = normalized; + SelectedByCharacter[CharacterKey()] = normalized; + SaveIndex(); + WriteLegacyExport(normalized, document); + notice = copyCurrent + ? $"Copied Meta profile to {normalized}." + : $"Created Meta profile {normalized}."; + return true; + } + + public bool TryImportLegacy( + string? name, + out MetaProfile profile, + out string notice) + { + string normalized = Normalize(name); + if (!_host.Storage.IsAvailable || normalized.Length == 0) + { + profile = new MetaProfile(); + notice = "Legacy Meta storage is unavailable."; + return false; + } + string? key = _host.Storage.List("imports") + .Concat(_host.Storage.List("exports")) + .FirstOrDefault(candidate => + candidate.EndsWith(".met", StringComparison.OrdinalIgnoreCase) + && Path.GetFileNameWithoutExtension(candidate).Equals( + normalized, + StringComparison.OrdinalIgnoreCase)); + string? source = key is null ? null : _host.Storage.ReadText(key); + if (string.IsNullOrWhiteSpace(source)) + { + profile = new MetaProfile(); + notice = $"VTank Meta file '{normalized}.met' was not found in imports."; + return false; + } + if (!VtankMetaProfileSerializer.TryLoad(source, out profile, out string error)) + { + notice = $"Could not import {Path.GetFileName(key)}: {error}"; + return false; + } + if (!Names.Contains(normalized, StringComparer.OrdinalIgnoreCase)) + Names.Add(normalized); + _selected = Names.First(existing => existing.Equals( + normalized, + StringComparison.OrdinalIgnoreCase)); + SelectedByCharacter[CharacterKey()] = _selected; + SaveIndex(); + SaveCurrent(profile); + notice = $"Imported VTank Meta profile {_selected}."; + return true; + } + + public MetaProfile ClearCurrent() + { + var empty = new MetaProfile(); + SaveCurrent(empty); + return empty; + } + + private string CurrentKey() => _selected.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) + ? $"profiles/meta/by-character/{Hash(_character)}.json" + : NamedKey(_selected); + + private static string NamedKey(string name) => + $"profiles/meta/named/{Hash(name)}.json"; + + private string CharacterKey() => + string.IsNullOrWhiteSpace(_character) ? "anonymous" : _character; + + private bool IsKnown(string name) => name.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) + || Names.Contains(name, StringComparer.OrdinalIgnoreCase); + + private string Canonical(string name) => name.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) + ? ByCharacter + : Names.First(existing => existing.Equals( + name, + StringComparison.OrdinalIgnoreCase)); + + private void SaveIndex() => Write(IndexKey, _index); + + private void WriteLegacyExport(string name, MetaProfile profile) + { + if (!_host.Storage.IsAvailable) + return; + try + { + _host.Storage.WriteText( + $"exports/{LegacyFileName(name)}.met", + VtankMetaProfileSerializer.Save(profile)); + } + catch (Exception error) + { + _host.Log.Warn( + $"MossTank VTank Meta export could not be saved: {error.Message}"); + } + } + + private string LegacyProfileName() => _selected.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) + ? string.IsNullOrWhiteSpace(_character) ? ByCharacter : _character + : _selected; + + private static string LegacyFileName(string name) + { + char[] invalid = Path.GetInvalidFileNameChars(); + var result = new StringBuilder(name.Length); + foreach (char value in name.Trim()) + { + result.Append(value is '/' or '\\' || invalid.Contains(value) + ? '_' + : value); + } + return result.Length == 0 ? "Meta" : result.ToString(); + } + + private T? Read(string key) + { + if (!_host.Storage.IsAvailable) + return default; + string? json = null; + try + { + json = _host.Storage.ReadText(key); + return string.IsNullOrWhiteSpace(json) + ? default + : JsonSerializer.Deserialize(json, Options); + } + catch (Exception error) + { + RecoveryNotice = MossTankProfileRecovery.Preserve( + _host, + "meta", + key, + json, + error); + _host.Log.Error(RecoveryNotice, error); + return default; + } + } + + private void Write(string key, T value) + { + if (!_host.Storage.IsAvailable) + return; + try + { + _host.Storage.WriteText(key, JsonSerializer.Serialize(value, Options)); + } + catch (Exception error) + { + _host.Log.Error($"Unable to save MossTank Meta profile '{key}'.", error); + } + } + + private static MetaProfile Clone(MetaProfile profile) => + JsonSerializer.Deserialize( + JsonSerializer.Serialize(profile, Options), + Options) ?? new MetaProfile(); + + private static string Normalize(string? name) => name?.Trim() ?? string.Empty; + + private static string Hash(string value) + { + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(value.ToLowerInvariant())); + return Convert.ToHexString(hash.AsSpan(0, 12)).ToLowerInvariant(); + } + + private sealed class IndexDocument + { + public List? Names { get; set; } = []; + public Dictionary? SelectedByCharacter { get; set; } = []; + } +} diff --git a/src/AcDream.Plugins.MossTank/MossTankPanel.cs b/src/AcDream.Plugins.MossTank/MossTankPanel.cs index db09934e..ebc1ca9b 100644 --- a/src/AcDream.Plugins.MossTank/MossTankPanel.cs +++ b/src/AcDream.Plugins.MossTank/MossTankPanel.cs @@ -1,5 +1,7 @@ using System.Globalization; +using System.Text; using AcDream.Plugin.Abstractions; +using AcDream.Plugins.MossTank.Expressions; namespace AcDream.Plugins.MossTank; @@ -11,8 +13,21 @@ namespace AcDream.Plugins.MossTank; /// tick all arrive there — so no locking is used, deliberately: a lock would /// imply a second thread that does not exist. /// -internal sealed class MossTankPanel +internal sealed partial class MossTankPanel { + private enum TankTab + { + Options, + Profiles, + Vitals, + Monsters, + Items, + Consumables, + Buffs, + Route, + Meta, + } + /// /// Give up on a pass that stops making progress. Generous, because the /// pacing is now the server acknowledging each cast rather than a fixed @@ -30,6 +45,27 @@ internal sealed class MossTankPanel private readonly IPluginHost _host; private readonly BuffSettings _buffSettings = new(); private readonly VitalSettings _vitalSettings = new(); + private readonly CombatSettings _combatSettings = new(); + private readonly InventorySettings _inventorySettings = new(); + private readonly NavigationSettings _navigationSettings = new(); + private readonly MossTankProfileStore _profiles; + private readonly MossTankLootProfileStore _lootProfiles; + private readonly MossTankRouteProfileStore _routeProfiles; + private readonly MossTankMetaProfileStore _metaProfiles; + private readonly MetaViewManager _metaViews; + private readonly CombatController _combat; + private readonly VitalRechargeController _vitalRecharge; + private readonly DispelController _dispel; + private readonly InventoryMaintenanceController _inventoryMaintenance; + private readonly CraftingController _crafting; + private readonly ItemManaRechargeController _itemManaRecharge; + private readonly LootController _loot; + private readonly ProfileGiveController _profileGive; + private readonly NavigationController _navigation; + private readonly FellowshipManager _fellowshipManager; + private readonly MossTankExpressionRuntime _expressions; + private MetaProfile _metaProfile; + private readonly MetaEngine _meta; // A pass works through a queue captured at the start rather than a plan // re-derived each tick. Force Buff deliberately ignores what is already in @@ -38,6 +74,15 @@ internal sealed class MossTankPanel private List _queue = new(); private int _queueIndex; private bool _running; + private bool _forcePass; + private bool _announceBuffPass; + private double _automaticBuffScanRemaining; + private double _buffCastRecastRemaining; + private bool _fastCastMovementActive; + private long _fastCastStartCompletionRevision; + private double _fastCastMovementElapsed; + private double _randomHelperRemaining; + private int _randomHelperCursor; private double _sinceProgress; private int _castThisPass; private string _status = "Idle."; @@ -53,6 +98,77 @@ internal sealed class MossTankPanel private IReadOnlyList? _coverageSpellSnapshot; private int _coverageBuffLineCount; private double _coverageRefreshRemaining; + private TankTab _activeTab = TankTab.Options; + private readonly HashSet _noBuffItemNames = + new(StringComparer.Ordinal); + private string _profileNotice = + "Select an inventory item, then add it to this profile."; + private string _profileNameDraft = string.Empty; + private string _profileLifecycleNotice = "Macro settings are stored by character."; + private IReadOnlyList _monsterRows = Array.Empty(); + private int _selectedMonsterRule; + private string _monsterExpressionDraft = "DEFAULT"; + private string _monsterEditorNotice = "Select a rule to edit."; + // PluginCore's three columns deliberately expose different cycles. The + // damage column is eDamageElement 0..13; Ex. Vuln omits Harm/Void/etc.; + // PetDmg adds VTank's PAuto sentinel. A shared Enum.GetNames list made + // several choices visible in columns where retail could never select + // them, and also exposed our internal "Electric"/"PlayerAuto" names. + private static readonly string[] MonsterDamageNames = + [ + "Pierce", "Bludgeon", "Slash", "Acid", "Lightning", "Cold", + "Fire", "Harm", "Auto", "Void Basic", "Drain Auto", "Prismatic", + "Random", "Fists", + ]; + private static readonly string[] MonsterExtraVulnerabilityNames = + [ + "Pierce", "Bludgeon", "Slash", "Acid", "Lightning", "Cold", + "Fire", "Auto", "None", + ]; + private static readonly string[] MonsterPetDamageNames = + [ + "Pierce", "Bludgeon", "Slash", "Acid", "Lightning", "Cold", + "Fire", "PAuto", "Auto", "None", + ]; + private IReadOnlyList _itemRows = Array.Empty(); + private IReadOnlyList _consumableRows = Array.Empty(); + private int _selectedItemRow; + private int _selectedConsumableRow; + private bool _lootEditorVisible; + private bool _advancedOptionsVisible; + private int _selectedAdvancedOption; + private string _advancedOptionValueDraft = string.Empty; + private string _advancedOptionNotice = + "All VTank settings are available here."; + private IReadOnlyList _lootRuleRows = Array.Empty(); + private int _selectedLootRule; + private string _lootExpressionDraft = "*"; + private string _lootEditorNotice = "Add a rule or select one to edit."; + private string _lootProfileNameDraft = string.Empty; + private IReadOnlyList _routeRows = Array.Empty(); + private int _selectedRouteWaypoint; + private string _routeProfileNameDraft = string.Empty; + private string _routeNotice = "Add the current position or a selected object."; + private string _routeChatDraft = "/ls"; + private int _routePauseSeconds = 5; + private RouteRecallKind _routeRecallKind = RouteRecallKind.PrimaryPortal; + private bool _routeAddToEnd = true; + private IReadOnlyList _metaRows = Array.Empty(); + private int _selectedMetaRule; + private string _metaProfileNameDraft = string.Empty; + private string _metaStateDraft = MetaEngine.DefaultState; + private string _metaConditionTextDraft = string.Empty; + private string _metaActionTextDraft = string.Empty; + private string _metaSecondaryTextDraft = string.Empty; + private MetaConditionKind _metaConditionKind = MetaConditionKind.Always; + private MetaActionKind _metaActionKind = MetaActionKind.None; + private int _metaNumber; + private int _metaSecondaryNumber; + private string _metaNotice = "Add a rule or select one to edit."; + private bool _applyingProfileOptions; + private bool _initialized; + private bool _firstRunGuidancePending; + private bool _automationWasAvailable; /// /// What the player had selected before the pass, so targeting yourself for @@ -60,22 +176,769 @@ internal sealed class MossTankPanel /// private uint? _selectionBeforePass; - public MossTankPanel(IPluginHost host) => _host = host; + public MossTankPanel(IPluginHost host) + { + _host = host; + _firstRunGuidancePending = NeedsFirstRunGuidance(host); + _profiles = new MossTankProfileStore(host); + _profiles.BindCharacter(host.Automation.Character.Name); + _profiles.LoadCurrent( + _combatSettings, + _buffSettings, + _vitalSettings, + _inventorySettings, + _noBuffItemNames); + _lootProfiles = new MossTankLootProfileStore(host); + _lootProfiles.BindCharacter(host.Automation.Character.Name); + if (!_lootProfiles.LoadCurrent( + _inventorySettings.Loot.Rules, + _inventorySettings.Loot)) + { + _lootProfiles.SaveCurrent( + _inventorySettings.Loot.Rules, + _inventorySettings.Loot); + } + _routeProfiles = new MossTankRouteProfileStore(host); + _routeProfiles.BindCharacter(host.Automation.Character.Name); + if (!_routeProfiles.LoadCurrent(_navigationSettings)) + _routeProfiles.SaveCurrent(_navigationSettings); + _combat = new CombatController(host, _combatSettings, _vitalSettings); + _vitalRecharge = new VitalRechargeController( + host, + _vitalSettings, + _combatSettings); + _dispel = new DispelController(host, _vitalSettings); + _inventoryMaintenance = new InventoryMaintenanceController( + host, + _inventorySettings); + _crafting = new CraftingController( + host, + _inventorySettings, + _combatSettings); + _combat.BindAmmunitionCraftRequest( + _crafting.CanRequest, + _crafting.Request); + _itemManaRecharge = new ItemManaRechargeController( + host, + _inventorySettings, + _combatSettings); + _loot = new LootController( + host, + _inventorySettings.Loot); + _profileGive = new ProfileGiveController(host, _lootProfiles); + _navigation = new NavigationController(host, _navigationSettings); + _fellowshipManager = new FellowshipManager(host); + _metaProfiles = new MossTankMetaProfileStore(host); + _metaViews = new MetaViewManager(host); + _metaProfiles.BindCharacter(host.Automation.Character.Name); + _metaProfile = _metaProfiles.LoadCurrent(); + _expressions = new MossTankExpressionRuntime(host); + _meta = new MetaEngine( + host, + _expressions, + _metaProfile, + new MetaServices + { + IsNavigationRouteEmpty = () => _navigationSettings.Waypoints.Count == 0, + NeedsBuff = () => BuildPlan(_host.Automation, force: false).Count != 0, + DistanceFromAnyRoutePoint = DistanceFromAnyRoutePoint, + CountMonstersByPriority = CountMonstersByPriority, + LoadEmbeddedNavigationRoute = LoadEmbeddedNavigationRoute, + GetOption = GetMetaOption, + SetOption = SetMetaOption, + CreateView = _metaViews.Create, + DestroyView = _metaViews.Destroy, + DestroyAllViews = _metaViews.DestroyAll, + }); + RegisterVtankExpressionFunctions(); + _initialized = true; + ApplyPersistedOptionOverrides(); + RefreshMonsterEditor(); + RefreshItemEditors(); + RefreshLootEditor(); + RefreshRouteEditor(); + RefreshMetaEditor(); + _automationWasAvailable = host.Automation.IsAvailable; + } // ── main panel bindings ─────────────────────────────────────────────── public Action Buff => StartOrStop; - public Action OpenSettings => () => SettingsOpen = true; - public Action CloseSettings => () => SettingsOpen = false; + public Action ForceBuff => StartForceBuff; + public Action CancelForceBuff => CancelForceBuffCore; + public Action ToggleCombat => ToggleMacro; + public Action ToggleCombatEnabled => () => + { + _combatSettings.Enabled = !_combatSettings.Enabled; + SaveProfile(); + }; + public Action ToggleAutoFellowManagement => () => SetMetaOption( + "AutoFellowManagement", + ExpressionValue.Boolean(!AutoFellowManagementEnabled)); + public Action ShowOptions => () => SelectTab(TankTab.Options); + public Action ShowProfiles => () => SelectTab(TankTab.Profiles); + public Action ShowVitals => () => SelectTab(TankTab.Vitals); + public Action ShowMonsters => () => SelectTab(TankTab.Monsters); + public Action ShowItems => () => SelectTab(TankTab.Items); + public Action ShowConsumables => () => SelectTab(TankTab.Consumables); + public Action ShowBuffs => () => SelectTab(TankTab.Buffs); + public Action ShowRoute => () => SelectTab(TankTab.Route); + public Action ShowMeta => () => SelectTab(TankTab.Meta); - public bool SettingsOpen { get; private set; } + /// Keeps the gameplay window off login/character-select screens. + public bool WindowAvailable => _host.Automation.IsAvailable; - /// Keeps the windows off the character-select and login screens. - public bool MainVisible => _host.Automation.IsAvailable && !SettingsOpen; - public bool SettingsVisible => _host.Automation.IsAvailable && SettingsOpen; + internal ExpressionValue EvaluateExpression(string source) => + _expressions.Evaluate(source); + + internal IReadOnlyCollection ExpressionFunctionNames => + _expressions.Functions.Select(static function => function.Name).ToArray(); + + public bool OptionsSelected => _activeTab == TankTab.Options; + public bool ProfilesSelected => _activeTab == TankTab.Profiles; + public bool VitalsSelected => _activeTab == TankTab.Vitals; + public bool MonstersSelected => _activeTab == TankTab.Monsters; + public bool ItemsSelected => _activeTab == TankTab.Items; + public bool ConsumablesSelected => _activeTab == TankTab.Consumables; + public bool BuffsSelected => _activeTab == TankTab.Buffs; + public bool RouteSelected => _activeTab == TankTab.Route; + public bool MetaSelected => _activeTab == TankTab.Meta; + + public bool OptionsTabEnabled => true; + public bool ProfilesTabEnabled => true; + public bool VitalsTabEnabled => true; + public bool MonstersTabEnabled => true; + public bool ItemsTabEnabled => true; + public bool ConsumablesTabEnabled => true; + public bool BuffsTabEnabled => true; + public bool RouteTabEnabled => true; + public bool MetaTabEnabled => true; + + public bool OptionsVisible => OptionsSelected + && !_lootEditorVisible + && !_advancedOptionsVisible; + public bool ProfilesVisible => ProfilesSelected && !_lootEditorVisible; + public bool VitalsVisible => VitalsSelected && !_lootEditorVisible; + public bool MonstersVisible => MonstersSelected && !_lootEditorVisible; + public bool ItemsVisible => ItemsSelected && !_lootEditorVisible; + public bool ConsumablesVisible => ConsumablesSelected && !_lootEditorVisible; + public bool BuffsVisible => BuffsSelected && !_lootEditorVisible; + public bool RouteVisible => RouteSelected && !_lootEditorVisible; + public bool MetaVisible => MetaSelected && !_lootEditorVisible; + public bool LootEditorVisible => _lootEditorVisible; + public bool AdvancedOptionsVisible => _advancedOptionsVisible; + public IReadOnlyList AdvancedOptionNames => VtankOptionCatalog.Names; + public int SelectedAdvancedOptionIndex => _selectedAdvancedOption; + public string AdvancedOptionName => VtankOptionCatalog.Names[ + Math.Clamp( + _selectedAdvancedOption, + 0, + VtankOptionCatalog.Names.Length - 1)]; + public string AdvancedOptionValueDraft => _advancedOptionValueDraft; + public string AdvancedOptionNotice => _advancedOptionNotice; + public Action ShowAdvancedOptions => () => + { + _advancedOptionsVisible = true; + LoadAdvancedOptionDraft(); + }; + public Action HideAdvancedOptions => () => _advancedOptionsVisible = false; + public Action SelectAdvancedOption => index => + { + _selectedAdvancedOption = Math.Clamp( + index, + 0, + VtankOptionCatalog.Names.Length - 1); + LoadAdvancedOptionDraft(); + }; + public Action SetAdvancedOptionValueDraft => value => + _advancedOptionValueDraft = value; + public Action SubmitAdvancedOption => value => + { + _advancedOptionValueDraft = value; + ApplyAdvancedOptionCore(); + }; + public Action ApplyAdvancedOption => ApplyAdvancedOptionCore; /// The button is Force Buff; while a pass runs it cancels. - public string ButtonText => _running ? "Stop" : "Force Buff"; - public string Status => _status; + public string BuffButtonText => _running ? "Stop buffing" : "Force buff"; + public string BuffStatus => _status; + public string CombatButtonText => _combat.ButtonText; + public string CombatStatus => _combat.Status; + public string CombatTarget => _combat.TargetText; + public string CombatMode => _combat.ModeText; + public string VitalStatus => _vitalRecharge.Status; + public string DispelStatus => _dispel.Status; + public string InventoryMaintenanceStatus => _inventoryMaintenance.Status; + public string CraftingStatus => _crafting.Status; + public string ItemManaRechargeStatus => _itemManaRecharge.Status; + public string LootStatus => _loot.Status; + public bool CombatEnabled => _combatSettings.Enabled; + public bool BuffingEnabled => _buffSettings.Enabled; + public bool IdlePeaceModeEnabled => _combatSettings.IdlePeaceMode; + public bool IdleBuffTopoffEnabled => _buffSettings.IdleBuffTopoff; + public bool ManaChargesWhenOffEnabled => + _inventorySettings.ManaChargesWhenOff; + public bool AutoFellowManagementEnabled => + _combatSettings.AutoFellowManagement; + public string FellowshipManagerStatus => _fellowshipManager.Status; + public bool AutoStackEnabled => _inventorySettings.AutoStack; + public bool AutoCramEnabled => _inventorySettings.AutoCram; + public bool AutoCraftItemsEnabled => _inventorySettings.AutoCraftItems; + public bool CastDispelSelfEnabled => _vitalSettings.CastDispelSelf; + public bool UseDispelItemsEnabled => _vitalSettings.UseDispelItems; + public bool RefillWornManaEnabled => _inventorySettings.RefillWornMana; + public float RefillWornManaValue => _inventorySettings.RefillWornManaPercent / 100f; + public string RefillWornManaText => + $"Refill worn mana below {_inventorySettings.RefillWornManaPercent}%"; + public string ItemProfileText => ProfileText( + "Weapons / Wands / Shields / Pets", + _combatSettings.CombatItemNames); + public string ConsumableProfileText => ProfileText( + "Gems / Food / Kits / Potions / Charges / Grenades / Lockpicks", + _combatSettings.ConsumableNames); + public string ProfileNotice => _profileNotice; + public Action AddSelectedItem => () => AddSelectedProfileItem(noBuffs: false); + public Action AddSelectedItemNoBuffs => () => AddSelectedProfileItem(noBuffs: true); + public Action AddSelectedConsumable => AddSelectedConsumableCore; + public Action AddAllPeas => AddAllPeasCore; + public IReadOnlyList ItemRows => _itemRows; + public IReadOnlyList ConsumableRows => _consumableRows; + public int SelectedItemRowIndex => _selectedItemRow; + public int SelectedConsumableRowIndex => _selectedConsumableRow; + public Action SelectItemRow => index => + _selectedItemRow = ClampRow(index, _itemRows.Count); + public Action SelectConsumableRow => index => + _selectedConsumableRow = ClampRow(index, _consumableRows.Count); + public Action RemoveSelectedItem => RemoveSelectedItemCore; + public Action RemoveSelectedConsumable => RemoveSelectedConsumableCore; + public Action ToggleAutoStack => () => + { + _inventorySettings.AutoStack = !_inventorySettings.AutoStack; + _inventoryMaintenance.Reset(); + SaveProfile(); + }; + public Action ToggleAutoCram => () => + { + _inventorySettings.AutoCram = !_inventorySettings.AutoCram; + _inventoryMaintenance.Reset(); + SaveProfile(); + }; + public Action ToggleAutoCraftItems => () => + { + _inventorySettings.AutoCraftItems = !_inventorySettings.AutoCraftItems; + _crafting.Reset(); + SaveProfile(); + }; + public Action ToggleCastDispelSelf => () => SetMetaOption( + "CastDispelSelf", + ExpressionValue.Boolean(!_vitalSettings.CastDispelSelf)); + public Action ToggleUseDispelItems => () => SetMetaOption( + "UseDispelItems", + ExpressionValue.Boolean(!_vitalSettings.UseDispelItems)); + public Action ToggleRefillWornMana => () => + { + _inventorySettings.RefillWornMana = !_inventorySettings.RefillWornMana; + _itemManaRecharge.Reset(); + SaveProfile(); + }; + public Action SetRefillWornMana => value => + { + _inventorySettings.RefillWornManaPercent = Math.Clamp( + (int)MathF.Round(value * 100f), + 0, + 99); + SaveProfile(); + }; + + // ── Loot profile / editor ──────────────────────────────────────────── + public bool LootEnabled => _inventorySettings.Loot.Enabled; + public bool LootPriorityBoostEnabled => + _inventorySettings.Loot.PriorityBoost; + public bool LootAllCorpsesEnabled => + _inventorySettings.Loot.LootAllCorpses; + public bool LootFellowCorpsesEnabled => + _inventorySettings.Loot.LootFellowCorpses; + public bool LootOnlyRareCorpsesEnabled => + _inventorySettings.Loot.LootOnlyRareCorpses; + public bool ReadUnknownScrollsEnabled => + _inventorySettings.Loot.ReadUnknownScrolls; + public IReadOnlyList LootProfileNames => + _lootProfiles.AvailableNames; + public string LootProfileName => _lootProfiles.Selected; + public string LootProfileNameDraft => _lootProfileNameDraft; + public IReadOnlyList LootClassifierNames + { + get + { + var names = new List { "VTClassic" }; + names.AddRange(_host.LootClassifiers.Available.Select(FormatClassifier)); + string selected = SelectedLootClassifier; + if (!names.Contains(selected, StringComparer.Ordinal)) + names.Add(selected); + return names; + } + } + public string SelectedLootClassifier + { + get + { + string id = _inventorySettings.Loot.ExternalClassifierId; + if (string.IsNullOrWhiteSpace(id)) + return "VTClassic"; + foreach (PluginLootClassifierInfo info in + _host.LootClassifiers.Available) + { + if (string.Equals(info.Id, id, StringComparison.OrdinalIgnoreCase)) + return FormatClassifier(info); + } + return $"Unavailable [{id}]"; + } + } + public string LootRangeText => + $"Corpse range {_inventorySettings.Loot.CorpseApproachRange:0}m"; + public IReadOnlyList LootRuleRows => _lootRuleRows; + public int SelectedLootRuleIndex => _selectedLootRule; + public string LootExpressionDraft => _lootExpressionDraft; + public string LootEditorNotice => _lootEditorNotice; + public IReadOnlyList LootActionNames => Enum.GetNames(); + public string SelectedLootAction => SelectedLootRule?.Action.ToString() + ?? LootAction.Keep.ToString(); + public string LootPriorityText => + $"Priority {SelectedLootRule?.Priority ?? 0}"; + public string LootKeepCountText => + $"Keep up to {SelectedLootRule?.KeepCount ?? 1}"; + public Action ToggleLooting => () => + { + _inventorySettings.Loot.Enabled = !_inventorySettings.Loot.Enabled; + _loot.Reset(); + SaveProfile(); + }; + public Action ToggleLootPriorityBoost => () => + { + _inventorySettings.Loot.PriorityBoost = + !_inventorySettings.Loot.PriorityBoost; + SaveProfile(); + }; + public Action ToggleLootAllCorpses => () => + { + _inventorySettings.Loot.LootAllCorpses = + !_inventorySettings.Loot.LootAllCorpses; + SaveProfile(); + }; + public Action ToggleLootFellowCorpses => () => + { + _inventorySettings.Loot.LootFellowCorpses = + !_inventorySettings.Loot.LootFellowCorpses; + SaveProfile(); + }; + public Action ToggleLootOnlyRareCorpses => () => + { + _inventorySettings.Loot.LootOnlyRareCorpses = + !_inventorySettings.Loot.LootOnlyRareCorpses; + SaveProfile(); + }; + public Action ToggleReadUnknownScrolls => () => + { + _inventorySettings.Loot.ReadUnknownScrolls = + !_inventorySettings.Loot.ReadUnknownScrolls; + SaveProfile(); + }; + public Action ShowLootEditor => () => + { + _activeTab = TankTab.Profiles; + _lootEditorVisible = true; + RefreshLootEditor(); + }; + public Action SelectLootProfile => SelectLootProfileCore; + public Action SelectLootClassifier => value => + { + string? id = ResolveClassifierId(value); + if (id is null) + { + _profileLifecycleNotice = $"Loot engine '{value}' is unavailable."; + return; + } + _inventorySettings.Loot.ExternalClassifierId = id; + _loot.Reset(); + SaveProfile(); + _profileLifecycleNotice = id.Length == 0 + ? "Loot engine set to VTClassic." + : $"Loot engine set to {SelectedLootClassifier}."; + }; + public Action SetLootProfileNameDraft => value => + _lootProfileNameDraft = value; + public Action CreateNamedLootProfile => value => + { + _lootProfileNameDraft = value; + CreateLootProfileCore(copyCurrent: false); + }; + public Action CreateLootProfile => () => + CreateLootProfileCore(copyCurrent: false); + public Action CopyLootProfile => () => + CreateLootProfileCore(copyCurrent: true); + public Action ClearLootProfile => ClearLootProfileCore; + public Action CloseLootEditor => () => _lootEditorVisible = false; + public Action SelectLootRule => SelectLootRuleCore; + public Action SetLootExpressionDraft => value => + _lootExpressionDraft = value; + public Action ApplyLootExpression => value => + { + _lootExpressionDraft = value; + ApplyLootExpressionCore(); + }; + public Action ApplyLootRule => ApplyLootExpressionCore; + public Action AddLootRule => AddLootRuleCore; + public Action RemoveLootRule => RemoveLootRuleCore; + public Action MoveLootRuleUp => () => MoveLootRule(-1); + public Action MoveLootRuleDown => () => MoveLootRule(1); + public Action SelectLootAction => SelectLootActionCore; + public Action LootPriorityDown => () => UpdateSelectedLootRule(rule => + rule.Priority = Math.Max(-1000, rule.Priority - 1)); + public Action LootPriorityUp => () => UpdateSelectedLootRule(rule => + rule.Priority = Math.Min(1000, rule.Priority + 1)); + public Action LootKeepCountDown => () => UpdateSelectedLootRule(rule => + rule.KeepCount = Math.Max(0, rule.KeepCount - 1)); + public Action LootKeepCountUp => () => UpdateSelectedLootRule(rule => + rule.KeepCount = Math.Min(100000, rule.KeepCount + 1)); + public Action LootRangeDown => () => + { + _inventorySettings.Loot.CorpseApproachRange = Math.Max( + 2f, + _inventorySettings.Loot.CorpseApproachRange - 2f); + SaveProfile(); + }; + public Action LootRangeUp => () => + { + _inventorySettings.Loot.CorpseApproachRange = Math.Min( + 100f, + _inventorySettings.Loot.CorpseApproachRange + 2f); + SaveProfile(); + }; + + // ── Navigation / route profiles ────────────────────────────────────── + public bool NavigationEnabled => _navigationSettings.Enabled; + public bool NavigationPriorityEnabled => _navigationSettings.Priority; + public bool FollowAroundCornersEnabled => + _navigationSettings.FollowAroundCorners; + public bool OpenDoorsEnabled => _navigationSettings.OpenDoors; + public string NavigationStatus => _navigation.Status; + public IReadOnlyList RouteRows => _routeRows; + public int SelectedRouteWaypointIndex => _selectedRouteWaypoint; + public IReadOnlyList RouteModeNames => Enum.GetNames(); + public string SelectedRouteMode => _navigationSettings.Mode.ToString(); + public IReadOnlyList RouteRecallNames => + Enum.GetNames(); + public string SelectedRouteRecall => _routeRecallKind.ToString(); + public IReadOnlyList RouteProfileNames => + _routeProfiles.AvailableNames; + public string SelectedRouteProfile => _routeProfiles.Selected; + public string RouteProfileNameDraft => _routeProfileNameDraft; + public string RouteNotice => _routeNotice; + public string RouteChatDraft => _routeChatDraft; + public string RoutePauseText => $"{_routePauseSeconds} seconds"; + public string RouteMinimumDistanceText => string.Create( + CultureInfo.InvariantCulture, + $"Follow/Nav Min Distance: {_navigationSettings.MinimumDistanceMeters:0.0}m"); + public string RouteFollowTargetText => + _navigationSettings.FollowTargetObjectId == 0u + ? "Follow target: [None]" + : $"Follow target: {_navigationSettings.FollowTargetName}"; + public string RouteAddPositionText => _routeAddToEnd + ? "Add to End" + : "Insert After Selection"; + + public Action ToggleNavigation => () => + { + _navigationSettings.Enabled = !_navigationSettings.Enabled; + _navigation.Reset(); + SaveRouteProfile(); + }; + public Action ToggleNavigationPriority => () => + { + _navigationSettings.Priority = !_navigationSettings.Priority; + SaveRouteProfile(); + }; + public Action ToggleFollowAroundCorners => () => + { + _navigationSettings.FollowAroundCorners = + !_navigationSettings.FollowAroundCorners; + _navigation.Reset(); + SaveRouteProfile(); + }; + public Action ToggleOpenDoors => () => + { + _navigationSettings.OpenDoors = !_navigationSettings.OpenDoors; + _navigation.Reset(); + SaveRouteProfile(); + }; + public Action SelectRouteMode => value => + { + if (!Enum.TryParse(value, ignoreCase: true, out RouteMode mode)) + return; + _navigationSettings.Mode = mode; + if (mode == RouteMode.Target) + CaptureFollowTarget(); + _navigation.Reset(); + RefreshRouteEditor(); + SaveRouteProfile(); + }; + public Action SelectRouteRecall => value => + { + if (Enum.TryParse(value, ignoreCase: true, out RouteRecallKind recall)) + _routeRecallKind = recall; + }; + public Action SelectRouteWaypoint => index => + _selectedRouteWaypoint = ClampRow(index, _navigationSettings.Waypoints.Count); + public Action AddRoutePoint => AddRoutePointCore; + public Action AddRouteUseSelected => () => + AddSelectedObjectWaypoint(RouteWaypointType.UseNpc); + public Action AddRouteOpenVendor => () => + AddSelectedObjectWaypoint(RouteWaypointType.OpenVendor); + public Action AddRoutePortal => () => + AddSelectedObjectWaypoint(RouteWaypointType.PortalByName); + public Action AddRouteRecall => AddRouteRecallCore; + public Action AddRoutePause => AddRoutePauseCore; + public Action AddRouteChat => AddRouteChatCore; + public Action AddRouteCheckpoint => AddRouteCheckpointCore; + public Action AddRouteJump => AddRouteJumpCore; + public Action RemoveRouteWaypoint => RemoveRouteWaypointCore; + public Action MoveRouteWaypointUp => () => MoveRouteWaypoint(-1); + public Action MoveRouteWaypointDown => () => MoveRouteWaypoint(1); + public Action ToggleRouteAddPosition => () => + _routeAddToEnd = !_routeAddToEnd; + public Action RoutePauseDown => () => + _routePauseSeconds = Math.Max(0, _routePauseSeconds - 1); + public Action RoutePauseUp => () => + _routePauseSeconds = Math.Min(3600, _routePauseSeconds + 1); + public Action RouteMinimumDistanceDown => () => + { + _navigationSettings.MinimumDistanceMeters = Math.Max( + 0.5d, + _navigationSettings.MinimumDistanceMeters - 0.5d); + SaveRouteProfile(); + }; + public Action RouteMinimumDistanceUp => () => + { + _navigationSettings.MinimumDistanceMeters = Math.Min( + 50d, + _navigationSettings.MinimumDistanceMeters + 0.5d); + SaveRouteProfile(); + }; + public Action SetRouteChatDraft => value => + _routeChatDraft = value; + public Action SetRouteProfileNameDraft => value => + _routeProfileNameDraft = value; + public Action SelectRouteProfile => SelectRouteProfileCore; + public Action CreateNamedRouteProfile => value => + { + _routeProfileNameDraft = value; + CreateRouteProfileCore(copyCurrent: false); + }; + public Action CreateRouteProfile => () => + CreateRouteProfileCore(copyCurrent: false); + public Action CopyRouteProfile => () => + CreateRouteProfileCore(copyCurrent: true); + public Action ClearRouteProfile => ClearRouteProfileCore; + public Action SetFollowTarget => CaptureFollowTarget; + + // ── Meta profile / editor ──────────────────────────────────────────── + public bool MetaEnabled => _meta.Enabled; + public string MetaState => _meta.CurrentState; + public string MetaStateText => $"State: {MetaState}"; + public string MetaStatus => _meta.Status; + public IReadOnlyList MetaRows => _metaRows; + public int SelectedMetaRuleIndex => _selectedMetaRule; + public IReadOnlyList MetaConditionNames => + Enum.GetNames(); + public IReadOnlyList MetaActionNames => Enum.GetNames(); + public string SelectedMetaCondition => _metaConditionKind.ToString(); + public string SelectedMetaAction => _metaActionKind.ToString(); + public string MetaStateDraft => _metaStateDraft; + public string MetaConditionTextDraft => _metaConditionTextDraft; + public string MetaActionTextDraft => _metaActionTextDraft; + public string MetaSecondaryTextDraft => _metaSecondaryTextDraft; + public string MetaNumberText => _metaNumber.ToString(CultureInfo.InvariantCulture); + public string MetaNumberLabel => $"N: {MetaNumberText}"; + public string MetaSecondaryNumberText => + _metaSecondaryNumber.ToString(CultureInfo.InvariantCulture); + public string MetaSecondaryNumberLabel => $"N2: {MetaSecondaryNumberText}"; + public string MetaNotice => _metaNotice; + public IReadOnlyList MetaProfileNames => _metaProfiles.AvailableNames; + public string SelectedMetaProfile => _metaProfiles.Selected; + public string MetaProfileNameDraft => _metaProfileNameDraft; + public Action ToggleMeta => () => + { + _meta.SetEnabled(!_meta.Enabled); + _combatSettings.MetaState = _meta.CurrentState; + }; + public Action SelectMetaRule => SelectMetaRuleCore; + public Action SelectMetaCondition => value => + { + if (Enum.TryParse(value, ignoreCase: true, out MetaConditionKind parsed)) + _metaConditionKind = parsed; + }; + public Action SelectMetaAction => value => + { + if (Enum.TryParse(value, ignoreCase: true, out MetaActionKind parsed)) + _metaActionKind = parsed; + }; + public Action SetMetaStateDraft => value => _metaStateDraft = value; + public Action SetMetaConditionTextDraft => value => + _metaConditionTextDraft = value; + public Action SetMetaActionTextDraft => value => + _metaActionTextDraft = value; + public Action SetMetaSecondaryTextDraft => value => + _metaSecondaryTextDraft = value; + public Action MetaNumberDown => () => _metaNumber--; + public Action MetaNumberUp => () => _metaNumber++; + public Action MetaSecondaryNumberDown => () => _metaSecondaryNumber--; + public Action MetaSecondaryNumberUp => () => _metaSecondaryNumber++; + public Action AddMetaRule => AddMetaRuleCore; + public Action ApplyMetaRule => ApplyMetaRuleCore; + public Action RemoveMetaRule => RemoveMetaRuleCore; + public Action MoveMetaRuleUp => () => MoveMetaRule(-1); + public Action MoveMetaRuleDown => () => MoveMetaRule(1); + public Action SetMetaProfileNameDraft => value => + _metaProfileNameDraft = value; + public Action SelectMetaProfile => SelectMetaProfileCore; + public Action CreateNamedMetaProfile => value => + { + _metaProfileNameDraft = value; + CreateMetaProfileCore(copyCurrent: false); + }; + public Action CreateMetaProfile => () => CreateMetaProfileCore(copyCurrent: false); + public Action CopyMetaProfile => () => CreateMetaProfileCore(copyCurrent: true); + public Action ClearMetaProfile => ClearMetaProfileCore; + + // ── Profiles tab ───────────────────────────────────────────────────── + public IReadOnlyList MacroProfileNames => _profiles.AvailableNames; + public string SelectedMacroProfile => _profiles.Selected; + public string ProfileNameDraft => _profileNameDraft; + public string ProfileLifecycleNotice => ProfileRecoveryNotice + ?? _profileLifecycleNotice; + private string? ProfileRecoveryNotice => + _profiles.RecoveryNotice + ?? _lootProfiles.RecoveryNotice + ?? _routeProfiles.RecoveryNotice + ?? _metaProfiles.RecoveryNotice; + public bool MineOnlyEnabled => _profiles.MineOnly; + public Action SetProfileNameDraft => value => + _profileNameDraft = value; + public Action SelectMacroProfile => SelectProfile; + public Action CreateNamedProfile => value => + { + _profileNameDraft = value; + CreateProfileCore(copyCurrent: false); + }; + public Action CreateProfile => () => CreateProfileCore(copyCurrent: false); + public Action CopyProfile => () => CreateProfileCore(copyCurrent: true); + public Action ClearProfile => ClearProfileCore; + public Action ToggleMineOnly => () => + { + string before = _profiles.Selected; + _profiles.SetMineOnly(!_profiles.MineOnly); + if (!string.Equals(before, _profiles.Selected, StringComparison.OrdinalIgnoreCase)) + LoadSelectedProfile(); + _profileLifecycleNotice = _profiles.MineOnly + ? "Showing profiles owned by this character." + : "Showing profiles from all characters."; + }; + + // ── Monsters editor ────────────────────────────────────────────────── + public IReadOnlyList MonsterRows => _monsterRows; + public int SelectedMonsterRuleIndex => _selectedMonsterRule; + public string MonsterExpressionDraft => _monsterExpressionDraft; + public string MonsterEditorNotice => _monsterEditorNotice; + public IReadOnlyList DamageTypeNames => MonsterDamageNames; + public IReadOnlyList ExtraVulnerabilityNames => + MonsterExtraVulnerabilityNames; + public IReadOnlyList PetDamageTypeNames => MonsterPetDamageNames; + public string SelectedDamageType => DamageTypeDisplay( + SelectedMonsterActions.DamageType); + public string SelectedExtraVulnerability => + DamageTypeDisplay(SelectedMonsterActions.ExtraVulnerability); + public string SelectedPetDamage => DamageTypeDisplay( + SelectedMonsterActions.PetDamageType); + public string MonsterPriorityText => + $"Priority {SelectedMonsterActions.BoundedPriority}"; + public string MonsterEquipmentText => + $"Weapon {ItemDisplayName( + SelectedMonsterActions.WeaponObjectId, + SelectedMonsterActions.WeaponName)} " + + $"Offhand {ItemDisplayName( + SelectedMonsterActions.OffhandObjectId, + SelectedMonsterActions.OffhandName)}"; + + public Action SelectMonsterRule => SelectMonsterRuleCore; + public Action SetMonsterExpressionDraft => value => + _monsterExpressionDraft = value; + public Action ApplyMonsterExpression => value => + { + _monsterExpressionDraft = value; + ApplyMonsterExpressionCore(); + }; + public Action ApplyMonsterRule => ApplyMonsterExpressionCore; + public Action AddMonsterRule => AddMonsterRuleCore; + public Action AddSelectedMonster => AddSelectedMonsterCore; + public Action RemoveMonsterRule => RemoveMonsterRuleCore; + public Action MoveMonsterRuleUp => () => MoveMonsterRule(-1); + public Action MoveMonsterRuleDown => () => MoveMonsterRule(1); + public Action MonsterPriorityDown => () => UpdateSelectedMonsterActions( + actions => actions with { Priority = Math.Max(-1, actions.Priority - 1) }); + public Action MonsterPriorityUp => () => UpdateSelectedMonsterActions( + actions => actions with { Priority = Math.Min(4, actions.Priority + 1) }); + public Action SelectMonsterDamage => value => + SetMonsterDamage(value, extra: false); + public Action SelectMonsterExtraVulnerability => value => + SetMonsterDamage(value, extra: true); + public Action SelectMonsterPetDamage => value => + { + if (TryParseDamageType(value, out MonsterDamageType parsed)) + { + UpdateSelectedMonsterActions(actions => actions with + { + PetDamageType = parsed, + }); + } + }; + public Action SetMonsterWeapon => () => SetSelectedMonsterEquipment(offhand: false); + public Action SetMonsterOffhand => () => SetSelectedMonsterEquipment(offhand: true); + public Action ClearMonsterEquipment => () => UpdateSelectedMonsterActions( + actions => actions with + { + WeaponObjectId = 0u, + OffhandObjectId = 0u, + WeaponName = string.Empty, + OffhandName = string.Empty, + }); + + public bool MonsterFester => HasMonsterFlag(MonsterActionFlags.Fester); + public bool MonsterBroadside => HasMonsterFlag(MonsterActionFlags.Broadside); + public bool MonsterGravityWell => HasMonsterFlag(MonsterActionFlags.GravityWell); + public bool MonsterImperil => HasMonsterFlag(MonsterActionFlags.Imperil); + public bool MonsterYield => HasMonsterFlag(MonsterActionFlags.Yield); + public bool MonsterVulnerability => HasMonsterFlag(MonsterActionFlags.Vulnerability); + public bool MonsterAttack => HasMonsterFlag(MonsterActionFlags.Attack); + public bool MonsterRing => HasMonsterFlag(MonsterActionFlags.Ring); + public bool MonsterStreak => HasMonsterFlag(MonsterActionFlags.Streak); + public bool MonsterWeakening => HasMonsterFlag(MonsterActionFlags.WeakeningCurse); + public bool MonsterFestering => HasMonsterFlag(MonsterActionFlags.FesteringCurse); + public bool MonsterCorruption => HasMonsterFlag(MonsterActionFlags.Corruption); + public bool MonsterDestructive => HasMonsterFlag(MonsterActionFlags.DestructiveCurse); + public bool MonsterCorrosion => HasMonsterFlag(MonsterActionFlags.Corrosion); + public Action ToggleMonsterFester => () => ToggleMonsterFlag(MonsterActionFlags.Fester); + public Action ToggleMonsterBroadside => () => ToggleMonsterFlag(MonsterActionFlags.Broadside); + public Action ToggleMonsterGravityWell => () => ToggleMonsterFlag(MonsterActionFlags.GravityWell); + public Action ToggleMonsterImperil => () => ToggleMonsterFlag(MonsterActionFlags.Imperil); + public Action ToggleMonsterYield => () => ToggleMonsterFlag(MonsterActionFlags.Yield); + public Action ToggleMonsterVulnerability => () => ToggleMonsterFlag(MonsterActionFlags.Vulnerability); + public Action ToggleMonsterAttack => () => ToggleMonsterFlag(MonsterActionFlags.Attack); + public Action ToggleMonsterRing => () => ToggleMonsterFlag(MonsterActionFlags.Ring); + public Action ToggleMonsterStreak => () => ToggleMonsterFlag(MonsterActionFlags.Streak); + public Action ToggleMonsterWeakening => () => ToggleMonsterFlag(MonsterActionFlags.WeakeningCurse); + public Action ToggleMonsterFestering => () => ToggleMonsterFlag(MonsterActionFlags.FesteringCurse); + public Action ToggleMonsterCorruption => () => ToggleMonsterFlag(MonsterActionFlags.Corruption); + public Action ToggleMonsterDestructive => () => ToggleMonsterFlag(MonsterActionFlags.DestructiveCurse); + public Action ToggleMonsterCorrosion => () => ToggleMonsterFlag(MonsterActionFlags.Corrosion); /// Vitals line, using the same numbers the character panel shows. public string Vitals => _vitals; @@ -100,9 +963,15 @@ internal sealed class MossTankPanel public string RebuffText => $"Rebuff when under: {_buffSettings.RebuffWhenUnderSeconds / 60.0:0.#} min"; - public string ManaFloorText => $"Convert below mana: {Percent(_vitalSettings.ManaFloor)}"; - public string ManaTargetText => $"Stop converting at: {Percent(_vitalSettings.ManaTarget)}"; - public string StaminaFloorText => $"Keep stamina above: {Percent(_vitalSettings.StaminaFloor)}"; + public string NormalHealthText => Percent(_vitalSettings.NormalHealth); + public string NormalStaminaText => Percent(_vitalSettings.NormalStamina); + public string NormalManaText => Percent(_vitalSettings.NormalMana); + public string NoTargetHealthText => Percent(_vitalSettings.NoTargetHealth); + public string NoTargetStaminaText => Percent(_vitalSettings.NoTargetStamina); + public string NoTargetManaText => Percent(_vitalSettings.NoTargetMana); + public string HelperHealthText => Percent(_vitalSettings.HelperHealth); + public string HelperStaminaText => Percent(_vitalSettings.HelperStamina); + public string HelperManaText => Percent(_vitalSettings.HelperMana); public string VitalUpkeepText => $"Stamina to Mana / Revitalize: {OnOff(_vitalSettings.Enabled)}"; public string TrainedOnlyText => @@ -120,46 +989,2575 @@ internal sealed class MossTankPanel public string OtherText => $"Buff other self-spells: {OnOff(_buffSettings.BuffOther)}"; - public Action DifficultyDown => () => _buffSettings.SkillExcessOverDifficulty = - Math.Max(-100, _buffSettings.SkillExcessOverDifficulty - 5); - public Action DifficultyUp => () => _buffSettings.SkillExcessOverDifficulty = - Math.Min(100, _buffSettings.SkillExcessOverDifficulty + 5); + public bool VitalUpkeepEnabled => _vitalSettings.Enabled; + public bool HelpOthersEnabled => _vitalSettings.HelpOthers; + public bool TrainedOnlyEnabled => _buffSettings.BuffTrainedSkillsOnly; + public bool AttributesEnabled => _buffSettings.BuffAttributes; + public bool ProtectionsEnabled => _buffSettings.BuffProtections; + public bool AurasEnabled => _buffSettings.BuffAuras; + public bool BanesEnabled => _buffSettings.BuffBanes; + public bool RegenerationEnabled => _buffSettings.BuffRegeneration; + public bool OtherEnabled => _buffSettings.BuffOther; - public Action RebuffDown => () => _buffSettings.RebuffWhenUnderSeconds = - Math.Max(30, _buffSettings.RebuffWhenUnderSeconds - 30); - public Action RebuffUp => () => _buffSettings.RebuffWhenUnderSeconds = - Math.Min(1800, _buffSettings.RebuffWhenUnderSeconds + 30); + public string TargetMethodText => + $"Target selection: {_combatSettings.SelectionMethod}"; + public string TargetLockText => + $"Target lock: {OnOff(_combatSettings.TargetLock)}"; + public string AttackRangeText => + $"Maximum target range: {_combatSettings.MaximumRange:0}m"; + public string MonsterRangeValueText => + _combatSettings.MaximumRange.ToString("0.#", CultureInfo.InvariantCulture); + public string RingRangeValueText => + _combatSettings.RingDistance.ToString("0.#", CultureInfo.InvariantCulture); + public string ApproachRangeValueText => + _combatSettings.ApproachDistance.ToString("0.#", CultureInfo.InvariantCulture); + public string FollowNavMinimumValueText => + _navigationSettings.MinimumDistanceMeters.ToString( + "0.#", + CultureInfo.InvariantCulture); + public string AngleRangeText => + $"Angle-selection range: {_combatSettings.TargetSelectAngleRange:0}m"; + public string AttackHeightText => + $"Physical attack height: {_combatSettings.AttackHeight}"; + public string AttackPowerText => + $"Power / accuracy: {_combatSettings.AttackPower * 100f:0}%"; + public bool TargetLockEnabled => _combatSettings.TargetLock; + public bool SummonPetsEnabled => _combatSettings.SummonPets; + public bool CustomPetRangeEnabled => + _combatSettings.PetRangeMode == PetRangeMode.Custom; + public string PetRangeText => _combatSettings.PetRangeMode == PetRangeMode.Custom + ? $"Pet range: {_combatSettings.PetCustomRange:0}m" + : $"Pet range: attack ({_combatSettings.MaximumRange:0}m)"; + public string PetCustomRangeValueText => + _combatSettings.PetCustomRange.ToString("0.#", CultureInfo.InvariantCulture); + public string PetDensityText => + $"Pet min. monsters: {_combatSettings.PetMonsterDensity}"; + public string PetDensityValueText => + _combatSettings.PetMonsterDensity.ToString(CultureInfo.InvariantCulture); + public float NormalHealthValue => (float)_vitalSettings.NormalHealth; + public float NormalStaminaValue => (float)_vitalSettings.NormalStamina; + public float NormalManaValue => (float)_vitalSettings.NormalMana; + public float NoTargetHealthValue => (float)_vitalSettings.NoTargetHealth; + public float NoTargetStaminaValue => (float)_vitalSettings.NoTargetStamina; + public float NoTargetManaValue => (float)_vitalSettings.NoTargetMana; + public float HelperHealthValue => (float)_vitalSettings.HelperHealth; + public float HelperStaminaValue => (float)_vitalSettings.HelperStamina; + public float HelperManaValue => (float)_vitalSettings.HelperMana; + public float AttackPowerValue => _combatSettings.AttackPower; - public Action ManaFloorDown => () => _vitalSettings.ManaFloor = Step(_vitalSettings.ManaFloor, -1); - public Action ManaFloorUp => () => _vitalSettings.ManaFloor = Step(_vitalSettings.ManaFloor, +1); + public Action SetNormalHealth => value => + UpdateVital(() => _vitalSettings.NormalHealth = Clamp(value)); + public Action SetNormalStamina => value => + UpdateVital(() => _vitalSettings.NormalStamina = Clamp(value)); + public Action SetNormalMana => value => + UpdateVital(() => _vitalSettings.NormalMana = Clamp(value)); + public Action SetNoTargetHealth => value => + UpdateVital(() => _vitalSettings.NoTargetHealth = Clamp(value)); + public Action SetNoTargetStamina => value => + UpdateVital(() => _vitalSettings.NoTargetStamina = Clamp(value)); + public Action SetNoTargetMana => value => + UpdateVital(() => _vitalSettings.NoTargetMana = Clamp(value)); + public Action SetHelperHealth => value => + UpdateVital(() => _vitalSettings.HelperHealth = Clamp(value)); + public Action SetHelperStamina => value => + UpdateVital(() => _vitalSettings.HelperStamina = Clamp(value)); + public Action SetHelperMana => value => + UpdateVital(() => _vitalSettings.HelperMana = Clamp(value)); + public Action SetAttackPower => value => UpdateProfile(() => + _combatSettings.AttackPower = Math.Clamp(value, 0f, 1f)); - public Action ManaTargetDown => () => _vitalSettings.ManaTarget = Step(_vitalSettings.ManaTarget, -1); - public Action ManaTargetUp => () => _vitalSettings.ManaTarget = Step(_vitalSettings.ManaTarget, +1); + public Action DifficultyDown => () => UpdateProfile(() => + _buffSettings.SkillExcessOverDifficulty = + Math.Max(-100, _buffSettings.SkillExcessOverDifficulty - 5)); + public Action DifficultyUp => () => UpdateProfile(() => + _buffSettings.SkillExcessOverDifficulty = + Math.Min(100, _buffSettings.SkillExcessOverDifficulty + 5)); - public Action StaminaFloorDown => () => _vitalSettings.StaminaFloor = Step(_vitalSettings.StaminaFloor, -1); - public Action StaminaFloorUp => () => _vitalSettings.StaminaFloor = Step(_vitalSettings.StaminaFloor, +1); + public Action RebuffDown => () => UpdateProfile(() => + _buffSettings.RebuffWhenUnderSeconds = + Math.Max(30, _buffSettings.RebuffWhenUnderSeconds - 30)); + public Action RebuffUp => () => UpdateProfile(() => + _buffSettings.RebuffWhenUnderSeconds = + Math.Min(1800, _buffSettings.RebuffWhenUnderSeconds + 30)); - public Action ToggleVitalUpkeep => () => _vitalSettings.Enabled = !_vitalSettings.Enabled; - public Action ToggleTrainedOnly => () => - _buffSettings.BuffTrainedSkillsOnly = !_buffSettings.BuffTrainedSkillsOnly; - public Action ToggleAttributes => () => - _buffSettings.BuffAttributes = !_buffSettings.BuffAttributes; - public Action ToggleProtections => () => - _buffSettings.BuffProtections = !_buffSettings.BuffProtections; - public Action ToggleAuras => () => _buffSettings.BuffAuras = !_buffSettings.BuffAuras; - public Action ToggleBanes => () => _buffSettings.BuffBanes = !_buffSettings.BuffBanes; - public Action ToggleRegeneration => - () => _buffSettings.BuffRegeneration = !_buffSettings.BuffRegeneration; - public Action ToggleOther => () => _buffSettings.BuffOther = !_buffSettings.BuffOther; + public Action ToggleVitalUpkeep => () => + { + _vitalSettings.Enabled = !_vitalSettings.Enabled; + if (!_vitalSettings.Enabled) + _vitalRecharge.Reset(); + SaveProfile(); + }; + public Action ToggleBuffing => () => SetMetaOption( + "EnableBuffing", + ExpressionValue.Boolean(!_buffSettings.Enabled)); + public Action ToggleIdlePeaceMode => () => SetMetaOption( + "IdlePeaceMode", + ExpressionValue.Boolean(!_combatSettings.IdlePeaceMode)); + public Action ToggleIdleBuffTopoff => () => SetMetaOption( + "IdleBuffTopoff", + ExpressionValue.Boolean(!_buffSettings.IdleBuffTopoff)); + public Action ToggleManaChargesWhenOff => () => SetMetaOption( + "ManaChargesWhenOff", + ExpressionValue.Boolean(!_inventorySettings.ManaChargesWhenOff)); + public Action ToggleHelpOthers => () => UpdateVital(() => + _vitalSettings.HelpOthers = !_vitalSettings.HelpOthers); + public Action ToggleTrainedOnly => () => UpdateProfile(() => + _buffSettings.BuffTrainedSkillsOnly = !_buffSettings.BuffTrainedSkillsOnly); + public Action ToggleAttributes => () => UpdateProfile(() => + _buffSettings.BuffAttributes = !_buffSettings.BuffAttributes); + public Action ToggleProtections => () => UpdateProfile(() => + _buffSettings.BuffProtections = !_buffSettings.BuffProtections); + public Action ToggleAuras => () => UpdateProfile(() => + _buffSettings.BuffAuras = !_buffSettings.BuffAuras); + public Action ToggleBanes => () => UpdateProfile(() => + _buffSettings.BuffBanes = !_buffSettings.BuffBanes); + public Action ToggleRegeneration => () => UpdateProfile(() => + _buffSettings.BuffRegeneration = !_buffSettings.BuffRegeneration); + public Action ToggleOther => () => UpdateProfile(() => + _buffSettings.BuffOther = !_buffSettings.BuffOther); + public Action CycleTargetMethod => () => UpdateProfile(() => + _combatSettings.SelectionMethod = _combatSettings.SelectionMethod switch + { + TargetSelectionMethod.Range => TargetSelectionMethod.Angle, + TargetSelectionMethod.Angle => TargetSelectionMethod.Both, + _ => TargetSelectionMethod.Range, + }); + public Action ToggleTargetLock => () => UpdateProfile(() => + _combatSettings.TargetLock = !_combatSettings.TargetLock); + public Action ToggleSummonPets => () => UpdateProfile(() => + _combatSettings.SummonPets = !_combatSettings.SummonPets); + public Action TogglePetRangeMode => () => UpdateProfile(() => + _combatSettings.PetRangeMode = _combatSettings.PetRangeMode == PetRangeMode.Custom + ? PetRangeMode.AttackDistance + : PetRangeMode.Custom); + public Action PetRangeDown => () => UpdateProfile(() => + _combatSettings.PetCustomRange = + Math.Max(1f, _combatSettings.PetCustomRange - 1f)); + public Action PetRangeUp => () => UpdateProfile(() => + _combatSettings.PetCustomRange = + Math.Min(100f, _combatSettings.PetCustomRange + 1f)); + public Action SetPetCustomRangeText => value => + SetDistanceText(value, 1f, 100f, distance => + _combatSettings.PetCustomRange = distance); + public Action PetDensityDown => () => UpdateProfile(() => + _combatSettings.PetMonsterDensity = + Math.Max(1, _combatSettings.PetMonsterDensity - 1)); + public Action PetDensityUp => () => UpdateProfile(() => + _combatSettings.PetMonsterDensity = + Math.Min(25, _combatSettings.PetMonsterDensity + 1)); + public Action SetPetDensityText => value => + { + if (!int.TryParse( + value, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out int density)) + { + return; + } + _combatSettings.PetMonsterDensity = Math.Clamp(density, 1, 25); + SaveProfile(); + }; + public Action AttackRangeDown => () => UpdateProfile(() => + _combatSettings.MaximumRange = + Math.Max(2f, _combatSettings.MaximumRange - 2f)); + public Action AttackRangeUp => () => UpdateProfile(() => + _combatSettings.MaximumRange = + Math.Min(100f, _combatSettings.MaximumRange + 2f)); + public Action SetMonsterRangeText => value => + SetDistanceText(value, 1f, 100f, distance => + _combatSettings.MaximumRange = distance); + public Action SetRingRangeText => value => + SetDistanceText(value, 1f, 100f, distance => + _combatSettings.RingDistance = distance); + public Action SetApproachRangeText => value => + SetDistanceText(value, 0f, 100f, distance => + _combatSettings.ApproachDistance = distance); + public Action SetFollowNavMinimumText => value => + SetDistanceText(value, 0.5f, 50f, distance => + { + _navigationSettings.MinimumDistanceMeters = distance; + SaveRouteProfile(); + }, saveProfile: false); + public Action AngleRangeDown => () => UpdateProfile(() => + _combatSettings.TargetSelectAngleRange = + Math.Max(2f, _combatSettings.TargetSelectAngleRange - 2f)); + public Action AngleRangeUp => () => UpdateProfile(() => + _combatSettings.TargetSelectAngleRange = Math.Min( + _combatSettings.MaximumRange, + _combatSettings.TargetSelectAngleRange + 2f)); + public Action CycleAttackHeight => () => UpdateProfile(() => + _combatSettings.AttackHeight = _combatSettings.AttackHeight switch + { + PluginAttackHeight.High => PluginAttackHeight.Medium, + PluginAttackHeight.Medium => PluginAttackHeight.Low, + _ => PluginAttackHeight.High, + }); + public Action AttackPowerDown => () => UpdateProfile(() => + _combatSettings.AttackPower = + Math.Max(0f, MathF.Round(_combatSettings.AttackPower - 0.1f, 2))); + public Action AttackPowerUp => () => UpdateProfile(() => + _combatSettings.AttackPower = + Math.Min(1f, MathF.Round(_combatSettings.AttackPower + 0.1f, 2))); - private static double Step(double value, int direction) => - Math.Clamp(Math.Round(value + direction * 0.05, 2), 0.0, 1.0); + private static double Clamp(float value) => Math.Clamp((double)value, 0d, 1d); + + private void UpdateVital(Action update) + { + UpdateProfile(update); + } + + private void UpdateProfile(Action update) + { + update(); + SaveProfile(); + } + + private void SetDistanceText( + string text, + float minimum, + float maximum, + Action apply, + bool saveProfile = true) + { + if (!float.TryParse( + text, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out float distance) + && !float.TryParse( + text, + NumberStyles.Float, + CultureInfo.CurrentCulture, + out distance)) + { + return; + } + apply(Math.Clamp(distance, minimum, maximum)); + if (saveProfile) + SaveProfile(); + } private static string Percent(double fraction) => (fraction * 100).ToString("0", CultureInfo.InvariantCulture) + "%"; private static string OnOff(bool value) => value ? "on" : "off"; + private static string ProfileText(string heading, IEnumerable names) + { + string[] entries = names + .OrderBy(static name => name, StringComparer.Ordinal) + .Take(8) + .ToArray(); + return entries.Length == 0 + ? $"{heading}: [None]" + : $"{heading}: {string.Join(" | ", entries)}"; + } + + private void RefreshItemEditors() + { + RefreshConsumableCategories(); + _itemRows = _combatSettings.CombatItemNames + .OrderBy(static name => name, StringComparer.Ordinal) + .Select(name => _noBuffItemNames.Contains(name) + ? name + " [no buffs]" + : name) + .ToArray(); + _consumableRows = _combatSettings.ConsumableNames + .OrderBy(static name => name, StringComparer.Ordinal) + .ToArray(); + _selectedItemRow = ClampRow(_selectedItemRow, _itemRows.Count); + _selectedConsumableRow = ClampRow( + _selectedConsumableRow, + _consumableRows.Count); + } + + private static int ClampRow(int index, int count) => count == 0 + ? 0 + : Math.Clamp(index, 0, count - 1); + + private void RemoveSelectedItemCore() + { + string[] names = _combatSettings.CombatItemNames + .OrderBy(static name => name, StringComparer.Ordinal) + .ToArray(); + if (names.Length == 0) + { + _profileNotice = "The Items profile is empty."; + return; + } + string removed = names[ClampRow(_selectedItemRow, names.Length)]; + _combatSettings.CombatItemNames.Remove(removed); + _noBuffItemNames.Remove(removed); + _profileNotice = $"Removed {removed}."; + RefreshItemEditors(); + SaveProfile(); + } + + private void RemoveSelectedConsumableCore() + { + string[] names = _combatSettings.ConsumableNames + .OrderBy(static name => name, StringComparer.Ordinal) + .ToArray(); + if (names.Length == 0) + { + _profileNotice = "The Consumables profile is empty."; + return; + } + string removed = names[ClampRow(_selectedConsumableRow, names.Length)]; + _combatSettings.ConsumableNames.Remove(removed); + _combatSettings.ConsumableCategories.Remove(removed); + _profileNotice = $"Removed {removed}."; + RefreshItemEditors(); + SaveProfile(); + } + + private LootRule? SelectedLootRule => _inventorySettings.Loot.Rules.Count == 0 + ? null + : _inventorySettings.Loot.Rules[Math.Clamp( + _selectedLootRule, + 0, + _inventorySettings.Loot.Rules.Count - 1)]; + + private static string FormatClassifier(PluginLootClassifierInfo info) => + $"{info.DisplayName} [{info.Id}]"; + + private string? ResolveClassifierId(string? value) + { + if (string.Equals(value?.Trim(), "VTClassic", StringComparison.OrdinalIgnoreCase)) + return string.Empty; + foreach (PluginLootClassifierInfo info in _host.LootClassifiers.Available) + { + if (string.Equals(value?.Trim(), FormatClassifier(info), + StringComparison.Ordinal) + || string.Equals(value?.Trim(), info.Id, + StringComparison.OrdinalIgnoreCase)) + { + return info.Id; + } + } + return null; + } + + private void RefreshLootEditor(bool retainDraft = false) + { + int count = _inventorySettings.Loot.Rules.Count; + _selectedLootRule = count == 0 + ? 0 + : Math.Clamp(_selectedLootRule, 0, count - 1); + if (!retainDraft) + _lootExpressionDraft = SelectedLootRule?.Expression ?? "*"; + _lootRuleRows = _inventorySettings.Loot.Rules + .Select((rule, index) => + $"{index + 1}. {rule.Action} P{rule.Priority} " + + (rule.VtankRequirements.Count == 0 + ? rule.Expression + : $"[VTClassic: {rule.VtankRequirements.Count} requirements]")) + .ToArray(); + } + + private void SelectLootRuleCore(int index) + { + if (index < 0 || index >= _inventorySettings.Loot.Rules.Count) + return; + _selectedLootRule = index; + _lootExpressionDraft = + _inventorySettings.Loot.Rules[index].Expression; + _lootEditorNotice = $"Editing rule {index + 1}."; + RefreshLootEditor(retainDraft: true); + } + + private void SelectLootProfileCore(string name) + { + SaveProfile(); + if (!_lootProfiles.Select(name)) + { + _lootEditorNotice = $"Loot profile '{name}' is unavailable."; + return; + } + LoadLootProfile(); + _lootEditorNotice = $"Loaded loot profile {_lootProfiles.Selected}."; + } + + private void CreateLootProfileCore(bool copyCurrent) + { + if (!_lootProfiles.Create( + _lootProfileNameDraft, + copyCurrent, + _inventorySettings.Loot.Rules, + out string notice, + _inventorySettings.Loot)) + { + _lootEditorNotice = notice; + return; + } + _lootProfileNameDraft = string.Empty; + LoadLootProfile(); + _lootEditorNotice = notice; + } + + private void ClearLootProfileCore() + { + _lootProfiles.ClearCurrent( + _inventorySettings.Loot.Rules, + _inventorySettings.Loot); + _loot.Reset(); + RefreshLootEditor(); + _lootEditorNotice = $"Cleared {_lootProfiles.Selected}."; + } + + private void LoadLootProfile() + { + if (!_lootProfiles.LoadCurrent( + _inventorySettings.Loot.Rules, + _inventorySettings.Loot)) + { + _lootProfiles.SaveCurrent( + _inventorySettings.Loot.Rules, + _inventorySettings.Loot); + } + _loot.Reset(); + RefreshLootEditor(); + } + + private void ApplyLootExpressionCore() + { + if (SelectedLootRule is not { } rule) + { + _lootEditorNotice = "Add a loot rule first."; + return; + } + try + { + _ = LootRuleExpression.Compile(_lootExpressionDraft); + rule.Expression = _lootExpressionDraft; + rule.CustomExpression = string.Empty; + rule.VtankRequirements.Clear(); + _lootEditorNotice = $"Updated {rule.Name}."; + _loot.Reset(); + RefreshLootEditor(); + SaveProfile(); + } + catch (FormatException error) + { + _lootEditorNotice = error.Message; + } + } + + private void AddLootRuleCore() + { + var rule = new LootRule + { + Name = $"Rule {_inventorySettings.Loot.Rules.Count + 1}", + Expression = "*", + Action = LootAction.Keep, + }; + _inventorySettings.Loot.Rules.Add(rule); + _selectedLootRule = _inventorySettings.Loot.Rules.Count - 1; + _lootExpressionDraft = rule.Expression; + _lootEditorNotice = $"Added {rule.Name}."; + _loot.Reset(); + RefreshLootEditor(); + SaveProfile(); + } + + private void RemoveLootRuleCore() + { + if (SelectedLootRule is not { } rule) + { + _lootEditorNotice = "The loot profile is empty."; + return; + } + _inventorySettings.Loot.Rules.RemoveAt(_selectedLootRule); + _selectedLootRule = Math.Min( + _selectedLootRule, + Math.Max(0, _inventorySettings.Loot.Rules.Count - 1)); + _lootEditorNotice = $"Removed {rule.Name}."; + _loot.Reset(); + RefreshLootEditor(); + SaveProfile(); + } + + private void MoveLootRule(int direction) + { + if (SelectedLootRule is not { } rule) + return; + int destination = _selectedLootRule + Math.Sign(direction); + if (destination < 0 || destination >= _inventorySettings.Loot.Rules.Count) + return; + _inventorySettings.Loot.Rules.RemoveAt(_selectedLootRule); + _inventorySettings.Loot.Rules.Insert(destination, rule); + _selectedLootRule = destination; + _lootEditorNotice = $"Moved {rule.Name}."; + _loot.Reset(); + RefreshLootEditor(); + SaveProfile(); + } + + private void SelectLootActionCore(string value) + { + if (SelectedLootRule is not { } rule + || !Enum.TryParse(value, ignoreCase: true, out LootAction action)) + { + return; + } + rule.Action = action; + _lootEditorNotice = $"{rule.Name}: {action}."; + _loot.Reset(); + RefreshLootEditor(); + SaveProfile(); + } + + private void UpdateSelectedLootRule(Action update) + { + if (SelectedLootRule is not { } rule) + return; + update(rule); + _lootEditorNotice = $"Updated {rule.Name}."; + _loot.Reset(); + RefreshLootEditor(); + SaveProfile(); + } + + private void RefreshRouteEditor() + { + _selectedRouteWaypoint = ClampRow( + _selectedRouteWaypoint, + _navigationSettings.Waypoints.Count); + int active = _navigation.CurrentWaypointIndex; + _routeRows = _navigationSettings.Waypoints + .Select((waypoint, index) => + $"{(index == active && _navigationSettings.Enabled ? "<<" : " ")} " + + waypoint.DisplayText) + .ToArray(); + } + + private void AddRoutePointCore() + { + PluginNavigationSnapshot snapshot = + _host.Automation.Navigation.Snapshot; + if (!snapshot.IsAvailable) + { + _routeNotice = "Current position is unavailable."; + return; + } + AddRouteWaypoint(new RouteWaypoint + { + Type = RouteWaypointType.Point, + Position = snapshot.Position, + }); + _routeNotice = $"Added point {RouteWaypoint.FormatPosition(snapshot.Position)}."; + } + + private void AddRouteCheckpointCore() + { + PluginNavigationSnapshot snapshot = + _host.Automation.Navigation.Snapshot; + if (!snapshot.IsAvailable) + { + _routeNotice = "Current position is unavailable."; + return; + } + AddRouteWaypoint(new RouteWaypoint + { + Type = RouteWaypointType.Checkpoint, + Position = snapshot.Position, + }); + _routeNotice = "Added checkpoint."; + } + + private void AddSelectedObjectWaypoint(RouteWaypointType type) + { + uint selected = _host.Selection.SelectedObjectId ?? 0u; + if (selected == 0u + || !_host.Automation.Navigation.TryGetObject( + selected, + out PluginNavigationObject target)) + { + _routeNotice = "Select a live portal, NPC, or vendor first."; + return; + } + AddRouteWaypoint(new RouteWaypoint + { + Type = type, + Position = target.Position, + ObjectId = target.ObjectId, + ObjectName = target.Name, + }); + _routeNotice = $"Added {type}: {target.Name}."; + } + + private void AddRouteRecallCore() + { + AddRouteWaypoint(new RouteWaypoint + { + Type = RouteWaypointType.Recall, + Recall = _routeRecallKind, + Position = _host.Automation.Navigation.Snapshot.Position, + }); + _routeNotice = $"Added {RouteWaypoint.RecallDisplayName(_routeRecallKind)}."; + } + + private void AddRoutePauseCore() + { + AddRouteWaypoint(new RouteWaypoint + { + Type = RouteWaypointType.Pause, + DurationMilliseconds = _routePauseSeconds * 1000, + Position = _host.Automation.Navigation.Snapshot.Position, + }); + _routeNotice = $"Added {_routePauseSeconds}-second pause."; + } + + private void AddRouteChatCore() + { + string text = _routeChatDraft.Trim(); + if (text.Length == 0) + { + _routeNotice = "Enter a chat command first."; + return; + } + AddRouteWaypoint(new RouteWaypoint + { + Type = RouteWaypointType.ChatCommand, + Text = text, + Position = _host.Automation.Navigation.Snapshot.Position, + }); + _routeNotice = $"Added chat command {text}."; + } + + private void AddRouteJumpCore() + { + PluginNavigationSnapshot snapshot = + _host.Automation.Navigation.Snapshot; + if (!snapshot.IsAvailable) + { + _routeNotice = "Current heading is unavailable."; + return; + } + AddRouteWaypoint(new RouteWaypoint + { + Type = RouteWaypointType.Jump, + Position = snapshot.Position, + JumpHeadingDegrees = snapshot.Position.HeadingDegrees, + JumpRun = true, + JumpChargeMilliseconds = 1000, + JumpDirection = RouteJumpDirection.Forward, + }); + _routeNotice = "Added forward jump."; + } + + private void AddRouteWaypoint(RouteWaypoint waypoint) + { + int insertion = _routeAddToEnd + ? _navigationSettings.Waypoints.Count + : Math.Min( + _navigationSettings.Waypoints.Count, + _selectedRouteWaypoint + 1); + _navigationSettings.Waypoints.Insert(insertion, waypoint); + _selectedRouteWaypoint = insertion; + _navigation.Reset(); + RefreshRouteEditor(); + SaveRouteProfile(); + } + + private void RemoveRouteWaypointCore() + { + if (_navigationSettings.Waypoints.Count == 0) + { + _routeNotice = "The route is empty."; + return; + } + int index = ClampRow( + _selectedRouteWaypoint, + _navigationSettings.Waypoints.Count); + string removed = _navigationSettings.Waypoints[index].DisplayText; + _navigationSettings.Waypoints.RemoveAt(index); + _selectedRouteWaypoint = ClampRow( + index, + _navigationSettings.Waypoints.Count); + _navigation.Reset(); + RefreshRouteEditor(); + SaveRouteProfile(); + _routeNotice = $"Removed {removed}."; + } + + private void MoveRouteWaypoint(int direction) + { + if (_navigationSettings.Waypoints.Count < 2) + return; + int source = ClampRow( + _selectedRouteWaypoint, + _navigationSettings.Waypoints.Count); + int destination = source + Math.Sign(direction); + if (destination < 0 || destination >= _navigationSettings.Waypoints.Count) + return; + RouteWaypoint waypoint = _navigationSettings.Waypoints[source]; + _navigationSettings.Waypoints.RemoveAt(source); + _navigationSettings.Waypoints.Insert(destination, waypoint); + _selectedRouteWaypoint = destination; + _navigation.Reset(); + RefreshRouteEditor(); + SaveRouteProfile(); + } + + private void CaptureFollowTarget() + { + uint selected = _host.Selection.SelectedObjectId ?? 0u; + if (selected == 0u + || !_host.Automation.Navigation.TryGetObject( + selected, + out PluginNavigationObject target)) + { + _routeNotice = "Select a live object to follow first."; + return; + } + _navigationSettings.FollowTargetObjectId = target.ObjectId; + _navigationSettings.FollowTargetName = target.Name; + _navigationSettings.Mode = RouteMode.Target; + _navigation.Reset(); + RefreshRouteEditor(); + SaveRouteProfile(); + _routeNotice = $"Following {target.Name}."; + } + + private void SelectRouteProfileCore(string name) + { + SaveRouteProfile(); + if (!_routeProfiles.Select(name)) + { + _routeNotice = $"Route profile '{name}' is unavailable."; + return; + } + LoadRouteProfile(); + _routeNotice = $"Loaded route {_routeProfiles.Selected}."; + } + + private void CreateRouteProfileCore(bool copyCurrent) + { + if (!_routeProfiles.Create( + _routeProfileNameDraft, + copyCurrent, + _navigationSettings, + out string notice)) + { + _routeNotice = notice; + return; + } + _routeProfileNameDraft = string.Empty; + LoadRouteProfile(); + _routeNotice = notice; + } + + private void ClearRouteProfileCore() + { + _routeProfiles.ClearCurrent(_navigationSettings); + _navigation.Reset(); + RefreshRouteEditor(); + _routeNotice = $"Cleared {_routeProfiles.Selected}."; + } + + private void LoadRouteProfile() + { + if (!_routeProfiles.LoadCurrent(_navigationSettings)) + _routeProfiles.SaveCurrent(_navigationSettings); + if (_initialized) + ApplyPersistedOptionOverrides(); + _navigation.Reset(); + RefreshRouteEditor(); + } + + private void SaveRouteProfile() => + _routeProfiles.SaveCurrent(_navigationSettings); + + private void SelectTab(TankTab tab) + { + _activeTab = tab; + _lootEditorVisible = false; + _advancedOptionsVisible = false; + } + + private void LoadAdvancedOptionDraft() + { + _advancedOptionValueDraft = GetMetaOption(AdvancedOptionName) + .ToDisplayString(); + _advancedOptionNotice = $"Editing {AdvancedOptionName}."; + } + + private void ApplyAdvancedOptionCore() + { + if (!TryParseOptionValue( + _advancedOptionValueDraft.Trim(), + out ExpressionValue value)) + { + _advancedOptionNotice = "Enter a value first."; + return; + } + try + { + if (!SetMetaOption(AdvancedOptionName, value)) + { + _advancedOptionNotice = $"{AdvancedOptionName} is unavailable."; + return; + } + LoadAdvancedOptionDraft(); + _advancedOptionNotice = $"Applied {AdvancedOptionName}."; + } + catch (Exception exception) when (exception is FormatException + or OverflowException) + { + _advancedOptionNotice = exception.Message; + } + } + + private MonsterRule SelectedMonsterRule => _combatSettings.Rules.Count == 0 + ? new MonsterRule("DEFAULT", 0) + : _combatSettings.Rules[Math.Clamp( + _selectedMonsterRule, + 0, + _combatSettings.Rules.Count - 1)]; + private MonsterRuleActions SelectedMonsterActions => SelectedMonsterRule.Actions; + + private void RefreshMonsterEditor(bool retainDraft = false) + { + if (_combatSettings.Rules.Count == 0) + _combatSettings.Rules.Add(new MonsterRule("DEFAULT", 0)); + _selectedMonsterRule = Math.Clamp( + _selectedMonsterRule, + 0, + _combatSettings.Rules.Count - 1); + if (!retainDraft) + _monsterExpressionDraft = SelectedMonsterRule.Expression; + _monsterRows = _combatSettings.Rules.Select(FormatMonsterRow).ToArray(); + } + + private static string FormatMonsterRow(MonsterRule rule) + { + MonsterRuleActions actions = rule.Actions; + static char Mark(MonsterActionFlags flags, MonsterActionFlags flag) => + (flags & flag) != 0 ? '●' : '○'; + return string.Concat( + Mark(actions.Flags, MonsterActionFlags.Fester), " ", + Mark(actions.Flags, MonsterActionFlags.Broadside), " ", + Mark(actions.Flags, MonsterActionFlags.GravityWell), " ", + Mark(actions.Flags, MonsterActionFlags.Imperil), " ", + Mark(actions.Flags, MonsterActionFlags.Yield), " ", + Mark(actions.Flags, MonsterActionFlags.Vulnerability), " ", + Mark(actions.Flags, MonsterActionFlags.Attack), " ", + Mark(actions.Flags, MonsterActionFlags.Ring), " ", + Mark(actions.Flags, MonsterActionFlags.Streak), " ", + Mark(actions.Flags, MonsterActionFlags.WeakeningCurse), " ", + Mark(actions.Flags, MonsterActionFlags.FesteringCurse), " ", + Mark(actions.Flags, MonsterActionFlags.Corruption), " ", + Mark(actions.Flags, MonsterActionFlags.DestructiveCurse), " ", + Mark(actions.Flags, MonsterActionFlags.Corrosion), " ", + rule.Expression, " P", actions.BoundedPriority.ToString( + CultureInfo.InvariantCulture), " ", actions.DamageType.ToString()); + } + + private void SelectMonsterRuleCore(int index) + { + if (index < 0 || index >= _combatSettings.Rules.Count) + return; + _selectedMonsterRule = index; + _monsterExpressionDraft = SelectedMonsterRule.Expression; + _monsterEditorNotice = $"Editing row {index + 1}."; + RefreshMonsterEditor(); + } + + private void ApplyMonsterExpressionCore() + { + string expression = _monsterExpressionDraft.Trim(); + if (expression.Length == 0) + { + _monsterEditorNotice = "Monster expression cannot be empty."; + return; + } + try + { + _combatSettings.Rules[_selectedMonsterRule] = new MonsterRule( + expression, + SelectedMonsterActions); + _monsterEditorNotice = $"Updated {expression}."; + RefreshMonsterEditor(); + SaveProfile(); + } + catch (FormatException error) + { + _monsterEditorNotice = error.Message; + } + } + + private void AddMonsterRuleCore() => AddMonsterRuleCore("New monster"); + + private void AddMonsterRuleCore(string expression) + { + try + { + var rule = new MonsterRule(expression, new MonsterRuleActions()); + _combatSettings.Rules.Add(rule); + _selectedMonsterRule = _combatSettings.Rules.Count - 1; + _monsterEditorNotice = $"Added {expression}."; + RefreshMonsterEditor(); + SaveProfile(); + } + catch (FormatException error) + { + _monsterEditorNotice = error.Message; + } + } + + private void AddSelectedMonsterCore() + { + uint selectedId = _host.Selection.SelectedObjectId ?? 0u; + PluginCombatTarget selected = default; + bool found = false; + foreach (PluginCombatTarget candidate in + _host.Automation.Combat.CaptureHostileTargets(float.MaxValue)) + { + if (candidate.ObjectId != selectedId) + continue; + selected = candidate; + found = true; + break; + } + if (!found || string.IsNullOrWhiteSpace(selected.Name)) + { + _monsterEditorNotice = "Select a monster in the world first."; + return; + } + AddMonsterRuleCore(EscapeMonsterLiteral(selected.Name)); + } + + private static string EscapeMonsterLiteral(string value) + { + const string operators = "%/*+-#><=&|()"; + var result = new System.Text.StringBuilder(value.Length + 8); + foreach (char character in value) + { + if (char.IsDigit(character) + || character == '\\' + || operators.Contains(character)) + { + result.Append('\\'); + } + result.Append(character); + } + return result.ToString(); + } + + private void RemoveMonsterRuleCore() + { + if (SelectedMonsterRule.IsDefault) + { + _monsterEditorNotice = "DEFAULT cannot be removed."; + return; + } + string removed = SelectedMonsterRule.Expression; + _combatSettings.Rules.RemoveAt(_selectedMonsterRule); + _selectedMonsterRule = Math.Min( + _selectedMonsterRule, + _combatSettings.Rules.Count - 1); + _monsterEditorNotice = $"Removed {removed}."; + RefreshMonsterEditor(); + SaveProfile(); + } + + private void MoveMonsterRule(int direction) + { + int destination = _selectedMonsterRule + Math.Sign(direction); + if (destination < 0 || destination >= _combatSettings.Rules.Count) + return; + MonsterRule current = _combatSettings.Rules[_selectedMonsterRule]; + _combatSettings.Rules.RemoveAt(_selectedMonsterRule); + _combatSettings.Rules.Insert(destination, current); + _selectedMonsterRule = destination; + _monsterEditorNotice = $"Moved {current.Expression}."; + RefreshMonsterEditor(); + SaveProfile(); + } + + private bool HasMonsterFlag(MonsterActionFlags flag) => + (SelectedMonsterActions.Flags & flag) != 0; + + private void ToggleMonsterFlag(MonsterActionFlags flag) => + UpdateSelectedMonsterActions(actions => actions with + { + Flags = actions.Flags ^ flag, + }); + + private void SetMonsterDamage(string value, bool extra) + { + if (!TryParseDamageType(value, out MonsterDamageType parsed)) + return; + UpdateSelectedMonsterActions(actions => extra + ? actions with { ExtraVulnerability = parsed } + : actions with { DamageType = parsed }); + } + + private static string DamageTypeDisplay(MonsterDamageType value) => value switch + { + MonsterDamageType.Electric => "Lightning", + MonsterDamageType.VoidBasic or MonsterDamageType.Nether => "Void Basic", + MonsterDamageType.DrainAuto => "Drain Auto", + MonsterDamageType.PlayerAuto => "PAuto", + _ => value.ToString(), + }; + + private static bool TryParseDamageType( + string value, + out MonsterDamageType parsed) + { + parsed = value.Trim() switch + { + string name when name.Equals("Lightning", StringComparison.OrdinalIgnoreCase) => + MonsterDamageType.Electric, + string name when name.Equals("Void Basic", StringComparison.OrdinalIgnoreCase) => + MonsterDamageType.VoidBasic, + string name when name.Equals("Drain Auto", StringComparison.OrdinalIgnoreCase) => + MonsterDamageType.DrainAuto, + string name when name.Equals("PAuto", StringComparison.OrdinalIgnoreCase) => + MonsterDamageType.PlayerAuto, + _ => (MonsterDamageType)(-1), + }; + return (int)parsed >= 0 + || Enum.TryParse(value, ignoreCase: true, out parsed); + } + + private void SetSelectedMonsterEquipment(bool offhand) + { + if (!TryGetSelectedInventoryItem(out PluginInventoryItem item)) + { + _monsterEditorNotice = "Select an owned weapon or offhand item first."; + return; + } + _combatSettings.CombatItemNames.Add(item.Name); + _combatSettings.CombatItemObjectIds.Add(item.ObjectId); + RefreshItemEditors(); + UpdateSelectedMonsterActions(actions => offhand + ? actions with + { + OffhandObjectId = item.ObjectId, + OffhandName = item.Name, + } + : actions with + { + WeaponObjectId = item.ObjectId, + WeaponName = item.Name, + }); + } + + private string ItemDisplayName(uint objectId, string durableName) + { + if (!string.IsNullOrWhiteSpace(durableName)) + return durableName; + if (objectId == 0u) + return ""; + foreach (PluginInventoryItem item in + _host.Automation.Items.CaptureOwnedItems()) + { + if (item.ObjectId == objectId) + return item.Name; + } + return $"0x{objectId:X8}"; + } + + private void UpdateSelectedMonsterActions( + Func update) + { + MonsterRule selected = SelectedMonsterRule; + _combatSettings.Rules[_selectedMonsterRule] = new MonsterRule( + selected.Expression, + update(selected.Actions)); + _monsterEditorNotice = $"Updated {selected.Expression}."; + RefreshMonsterEditor(); + SaveProfile(); + } + + private void AddSelectedProfileItem(bool noBuffs) + { + if (!TryGetSelectedInventoryItem(out PluginInventoryItem item)) + { + _profileNotice = "Select an owned inventory item first."; + return; + } + _combatSettings.CombatItemObjectIds.Add(item.ObjectId); + _combatSettings.CombatItemNames.Add(item.Name); + if (noBuffs) + _noBuffItemNames.Add(item.Name); + else + _noBuffItemNames.Remove(item.Name); + _profileNotice = noBuffs + ? $"Added {item.Name} (no buffs)." + : $"Added {item.Name}."; + RefreshItemEditors(); + SaveProfile(); + } + + private void AddSelectedConsumableCore() + { + if (!TryGetSelectedInventoryItem(out PluginInventoryItem item)) + { + _profileNotice = "Select an owned consumable first."; + return; + } + _combatSettings.ConsumableNames.Add(item.Name); + _combatSettings.ConsumableCategories[item.Name] = + ConsumableClassifier.Classify(item); + _profileNotice = $"Added {item.Name}."; + RefreshItemEditors(); + SaveProfile(); + } + + private void AddAllPeasCore() + { + bool added = _combatSettings.ConsumableNames.Add( + CraftingPlanner.AllPeas); + _combatSettings.ConsumableCategories[CraftingPlanner.AllPeas] = + ConsumableCategory.AllPeas; + _profileNotice = added + ? "Added [All Peas]." + : "[All Peas] is already in this profile."; + if (added) + { + RefreshItemEditors(); + SaveProfile(); + } + } + + private void RefreshConsumableCategories() + { + foreach (string stale in _combatSettings.ConsumableCategories.Keys + .Where(name => !_combatSettings.ConsumableNames.Contains(name)) + .ToArray()) + { + _combatSettings.ConsumableCategories.Remove(stale); + } + foreach (string name in _combatSettings.ConsumableNames) + { + if (!_combatSettings.ConsumableCategories.ContainsKey(name)) + { + _combatSettings.ConsumableCategories[name] = + ConsumableClassifier.ClassifyName(name); + } + } + foreach (PluginInventoryItem item in + _host.Automation.Items.CaptureOwnedItems()) + { + if (_combatSettings.ConsumableNames.Contains(item.Name)) + { + _combatSettings.ConsumableCategories[item.Name] = + ConsumableClassifier.Classify(item); + } + } + } + + private bool TryGetSelectedInventoryItem(out PluginInventoryItem selected) + { + uint selectedId = _host.Selection.SelectedObjectId ?? 0u; + if (selectedId != 0u) + { + foreach (PluginInventoryItem item in + _host.Automation.Items.CaptureOwnedItems()) + { + if (item.ObjectId == selectedId) + { + selected = item; + return true; + } + } + } + selected = default; + return false; + } + + private MetaRule? SelectedMetaRule => + (uint)_selectedMetaRule < (uint)_metaProfile.Rules.Count + ? _metaProfile.Rules[_selectedMetaRule] + : null; + + private void RefreshMetaEditor() + { + _metaRows = _metaProfile.Rules.Select(static rule => + $"{rule.State,-16} {DescribeMetaCondition(rule.Condition),-34} " + + DescribeMetaAction(rule.Action)).ToArray(); + _selectedMetaRule = ClampRow(_selectedMetaRule, _metaProfile.Rules.Count); + if (SelectedMetaRule is not MetaRule selected) + return; + _metaStateDraft = selected.State; + _metaConditionKind = selected.Condition.Kind; + _metaConditionTextDraft = selected.Condition.Text; + _metaActionKind = selected.Action.Kind; + _metaActionTextDraft = selected.Action.Text; + _metaSecondaryTextDraft = selected.Action.SecondaryText; + _metaNumber = checked((int)Math.Clamp( + selected.Condition.Number, + int.MinValue, + int.MaxValue)); + _metaSecondaryNumber = checked((int)Math.Clamp( + selected.Condition.SecondaryNumber, + int.MinValue, + int.MaxValue)); + } + + private void SelectMetaRuleCore(int index) + { + _selectedMetaRule = ClampRow(index, _metaProfile.Rules.Count); + RefreshMetaEditor(); + _metaNotice = SelectedMetaRule is null + ? "Add a rule or select one to edit." + : "Editing the selected ordered Meta rule."; + } + + private void AddMetaRuleCore() + { + if (!TryBuildMetaRule(out MetaRule rule, out string error)) + { + _metaNotice = error; + return; + } + _metaProfile.Rules.Add(rule); + _selectedMetaRule = _metaProfile.Rules.Count - 1; + SaveMetaProfile(); + RefreshMetaEditor(); + _metaNotice = $"Added rule in {rule.State}."; + } + + private void ApplyMetaRuleCore() + { + if (SelectedMetaRule is not MetaRule current) + { + AddMetaRuleCore(); + return; + } + if (!TryBuildMetaRule(out MetaRule replacement, out string error)) + { + _metaNotice = error; + return; + } + replacement.Id = current.Id; + _metaProfile.Rules[_selectedMetaRule] = replacement; + SaveMetaProfile(); + RefreshMetaEditor(); + _metaNotice = $"Updated rule in {replacement.State}."; + } + + private bool TryBuildMetaRule(out MetaRule rule, out string error) + { + string state = string.IsNullOrWhiteSpace(_metaStateDraft) + ? MetaEngine.DefaultState + : _metaStateDraft.Trim(); + try + { + if (_metaConditionKind == MetaConditionKind.Expression) + _ = ExpressionProgram.Compile(_metaConditionTextDraft); + if (_metaActionKind is MetaActionKind.ExpressionAction + or MetaActionKind.ChatExpression) + { + _ = ExpressionProgram.Compile(_metaActionTextDraft); + } + rule = new MetaRule + { + State = state, + Condition = new MetaCondition + { + Kind = _metaConditionKind, + Text = _metaConditionTextDraft, + Number = _metaNumber, + SecondaryNumber = _metaSecondaryNumber, + }, + Action = new MetaAction + { + Kind = _metaActionKind, + Text = _metaActionTextDraft, + SecondaryText = _metaSecondaryTextDraft, + Number = _metaNumber, + SecondaryNumber = _metaSecondaryNumber, + }, + }; + error = string.Empty; + return true; + } + catch (Exception exception) + { + rule = new MetaRule(); + error = exception.Message; + return false; + } + } + + private void RemoveMetaRuleCore() + { + if (SelectedMetaRule is not MetaRule selected) + return; + _metaProfile.Rules.RemoveAt(_selectedMetaRule); + _selectedMetaRule = Math.Min( + _selectedMetaRule, + Math.Max(0, _metaProfile.Rules.Count - 1)); + SaveMetaProfile(); + RefreshMetaEditor(); + _metaNotice = $"Removed rule from {selected.State}."; + } + + private void MoveMetaRule(int direction) + { + int destination = _selectedMetaRule + Math.Sign(direction); + if ((uint)_selectedMetaRule >= (uint)_metaProfile.Rules.Count + || (uint)destination >= (uint)_metaProfile.Rules.Count) + { + return; + } + MetaRule rule = _metaProfile.Rules[_selectedMetaRule]; + _metaProfile.Rules.RemoveAt(_selectedMetaRule); + _metaProfile.Rules.Insert(destination, rule); + _selectedMetaRule = destination; + SaveMetaProfile(); + RefreshMetaEditor(); + _metaNotice = $"Moved {rule.State} rule."; + } + + private void SelectMetaProfileCore(string name) + { + SaveMetaProfile(); + if (!_metaProfiles.Select(name)) + { + _metaNotice = $"Meta profile '{name}' is unavailable."; + return; + } + LoadMetaProfile(); + _metaNotice = $"Loaded Meta profile {_metaProfiles.Selected}."; + } + + private void CreateMetaProfileCore(bool copyCurrent) + { + if (!_metaProfiles.Create( + _metaProfileNameDraft, + copyCurrent, + _metaProfile, + out string notice)) + { + _metaNotice = notice; + return; + } + _metaProfileNameDraft = string.Empty; + LoadMetaProfile(); + _metaNotice = notice; + } + + private void ClearMetaProfileCore() + { + _metaProfile = _metaProfiles.ClearCurrent(); + _meta.ReplaceProfile(_metaProfile); + _selectedMetaRule = 0; + RefreshMetaEditor(); + _metaNotice = $"Cleared Meta profile {_metaProfiles.Selected}."; + } + + private void LoadMetaProfile() + { + _metaProfile = _metaProfiles.LoadCurrent(); + _meta.ReplaceProfile(_metaProfile); + if (_initialized) + ApplyPersistedOptionOverrides(); + _selectedMetaRule = 0; + RefreshMetaEditor(); + } + + private void SaveMetaProfile() => _metaProfiles.SaveCurrent(_metaProfile); + + private static string DescribeMetaCondition(MetaCondition condition) => + condition.Kind switch + { + MetaConditionKind.Expression => $"Expression: {condition.Text}", + MetaConditionKind.ChatMessage or MetaConditionKind.ChatMessageCapture => + $"{condition.Kind}: {condition.Text}", + MetaConditionKind.Always or MetaConditionKind.Never => + condition.Kind.ToString(), + _ => $"{condition.Kind} {condition.Number:0.###}", + }; + + private static string DescribeMetaAction(MetaAction action) => action.Kind switch + { + MetaActionKind.SetMetaState or MetaActionKind.CallMetaState + or MetaActionKind.ChatCommand or MetaActionKind.ExpressionAction + or MetaActionKind.ChatExpression => $"{action.Kind}: {action.Text}", + _ => action.Kind.ToString(), + }; + + private double DistanceFromAnyRoutePoint() + { + PluginNavigationSnapshot player = _host.Automation.Navigation.Snapshot; + if (!player.IsAvailable) + return double.PositiveInfinity; + double nearest = double.PositiveInfinity; + foreach (RouteWaypoint waypoint in _navigationSettings.Waypoints) + { + if (waypoint.Position.CellId == 0u) + continue; + nearest = Math.Min( + nearest, + player.Position.HorizontalDistanceMeters(waypoint.Position)); + } + return nearest; + } + + private void LoadEmbeddedNavigationRoute(string source) + { + _navigation.Reset(); + if (!VtankNavRouteSerializer.TryLoad( + source, + _navigationSettings, + _host.Automation.Spells, + out string error)) + { + _routeNotice = $"Embedded route rejected: {error}"; + _host.Log.Warn($"MossTank Meta embedded route rejected: {error}"); + return; + } + _routeProfiles.SaveCurrent(_navigationSettings); + _selectedRouteWaypoint = 0; + RefreshRouteEditor(); + _routeNotice = $"Loaded embedded route ({_navigationSettings.Waypoints.Count} points)."; + } + + private int CountMonstersByPriority(int priority, double distance) + { + int count = 0; + foreach (PluginCombatTarget target in _host.Automation.Combat + .CaptureHostileTargets(checked((float)distance))) + { + if (_combatSettings.ResolveRule(target).Priority == priority) + count++; + } + return count; + } + + private ExpressionValue GetMetaOption(string name) + { + string key = name.Trim(); + return key.ToLowerInvariant() switch + { + "enablebuffing" => ExpressionValue.Boolean(_buffSettings.Enabled), + "enablecombat" => ExpressionValue.Boolean(_combatSettings.Enabled), + "enablenav" or "enablenavigation" or "enableautonavigator" => + ExpressionValue.Boolean(_navigationSettings.Enabled), + "enablelooting" => ExpressionValue.Boolean(_inventorySettings.Loot.Enabled), + "enablemeta" => ExpressionValue.Boolean(_meta.Enabled), + "spelldiffexcessthreshold-hunt" => ExpressionValue.Number( + _combatSettings.HuntSkillExcessOverDifficulty), + "spelldiffexcessthreshold-buff" => ExpressionValue.Number( + _buffSettings.SkillExcessOverDifficulty), + "arrowheadfletchdiffexcessthreshold" => ExpressionValue.Number( + _inventorySettings.ArrowheadFletchDifficultyExcess), + "dohelp" => ExpressionValue.Boolean(_vitalSettings.HelpOthers), + "monsterrange" => ExpressionValue.Number(_combatSettings.MaximumRange), + "attackdistance" => ExpressionValue.Number( + _combatSettings.MaximumRange / 240d), + "attackminimumdistance" => ExpressionValue.Number( + _combatSettings.MinimumRange / 240d), + "approachdistance" => ExpressionValue.Number( + _combatSettings.ApproachDistance / 240d), + "ringdistance" => ExpressionValue.Number(_combatSettings.RingDistance / 240d), + "arcrange" => ExpressionValue.Number(_combatSettings.ArcRange / 240d), + "targetselectanglerange" => ExpressionValue.Number( + _combatSettings.TargetSelectAngleRange / 240d), + "corpseapproachrange-max" => ExpressionValue.Number( + _inventorySettings.Loot.CorpseApproachRange / 240d), + "corpseapproachrange-min" => ExpressionValue.Number( + _inventorySettings.Loot.CorpseMinimumApproachRange / 240d), + "navclosestoprange" => ExpressionValue.Number( + _navigationSettings.MinimumDistanceMeters / 240d), + "navfarstoprange" => ExpressionValue.Number( + _navigationSettings.MaximumDistanceMeters / 240d), + "useportaldistance" => ExpressionValue.Number( + _navigationSettings.PortalUseDistanceMeters / 240d), + "helperdistancehitp" => ExpressionValue.Number( + _vitalSettings.HelperHealthDistance / 240d), + "helperdistancestam" => ExpressionValue.Number( + _vitalSettings.HelperStaminaDistance / 240d), + "helperdistancemana" => ExpressionValue.Number( + _vitalSettings.HelperManaDistance / 240d), + "minimumringtargets" => ExpressionValue.Number( + _combatSettings.MinimumRingTargets), + "defaultmeleeattackheight" => ExpressionValue.Number( + (int)_combatSettings.AttackHeight), + "defaultmeleeattackpower" or "attackpower" => + ExpressionValue.Number(_combatSettings.AttackPower), + "targetlock" => ExpressionValue.Boolean(_combatSettings.TargetLock), + "idlepeacemode" => ExpressionValue.Boolean( + _combatSettings.IdlePeaceMode), + "stopmacroondeath" => ExpressionValue.Boolean( + _combatSettings.StopMacroOnDeath), + "jumpoutwandcasting" => ExpressionValue.Boolean( + _combatSettings.JumpOutWandCasting), + "dojiggle" => ExpressionValue.Boolean(_combatSettings.DoJiggle), + "randomhelperbuffs" => ExpressionValue.Boolean( + _buffSettings.RandomHelperBuffs), + "randomhelperintervalseconds" => ExpressionValue.Number( + _buffSettings.RandomHelperIntervalSeconds), + "idlebufftopoff" => ExpressionValue.Boolean( + _buffSettings.IdleBuffTopoff), + "idlebufftopofftimeseconds" => ExpressionValue.Number( + _buffSettings.IdleBuffTopoffSeconds), + "buffprofile-prots" => ExpressionValue.String( + _buffSettings.ProtectionElements), + "buffprofile-banes" => ExpressionValue.String( + _buffSettings.BaneElements), + "buffprofile_prots" => ExpressionValue.Number( + _buffSettings.ProtectionProfileMode), + "buffprofile_banes" => ExpressionValue.Number( + _buffSettings.BaneProfileMode), + "targetselectmethod" => ExpressionValue.Number( + (int)_combatSettings.SelectionMethod + 1), + "autoattackpower" => ExpressionValue.Boolean( + _combatSettings.AutoAttackPower), + "userecklessness" => ExpressionValue.Boolean( + _combatSettings.UseRecklessness), + "debuffeachfirst" => ExpressionValue.Number( + (int)_combatSettings.DebuffEachFirst), + "debuffselectionmethod" => ExpressionValue.Number( + (int)_combatSettings.DebuffSelectionMethod), + "debuffprecastseconds" => ExpressionValue.Number( + _combatSettings.DebuffPrecastSeconds), + "switchwandstodebuff" => ExpressionValue.Boolean( + _combatSettings.SwitchWandsToDebuff), + "usearcs" => ExpressionValue.Boolean(_combatSettings.UseArcs), + "deleteghostmonsters" => ExpressionValue.Boolean( + _combatSettings.DeleteGhostMonsters), + "ghostmonsterspellattemptcount" => ExpressionValue.Number( + _combatSettings.GhostMonsterSpellAttemptCount), + "blacklistmonsterattemptcount" => ExpressionValue.Number( + _combatSettings.BlacklistMonsterAttemptCount), + "blacklistmonstertimeoutseconds" => ExpressionValue.Number( + _combatSettings.BlacklistMonsterTimeoutSeconds), + "deleteghostmonstersbyhptracker" => ExpressionValue.Boolean( + _combatSettings.DeleteGhostMonstersByHealthTracker), + "ghostdeletehptrackerseconds" => ExpressionValue.Number( + _combatSettings.GhostDeleteHealthTrackerSeconds), + "summonpets" => ExpressionValue.Boolean(_combatSettings.SummonPets), + "petrangemode" => ExpressionValue.Number((int)_combatSettings.PetRangeMode), + "petcustomrange" => ExpressionValue.Number( + _combatSettings.PetCustomRange / 240d), + "petmonsterdensity" => ExpressionValue.Number( + _combatSettings.PetMonsterDensity), + "petrefillcount-idle" => ExpressionValue.Number( + _combatSettings.PetRefillCountIdle), + "petrefillcount-normal" => ExpressionValue.Number( + _combatSettings.PetRefillCountNormal), + "openapproachdoors" or "opendoors" => + ExpressionValue.Boolean(_navigationSettings.OpenDoors), + "dooridrange" => ExpressionValue.Number( + _navigationSettings.DoorIdentifyRangeMeters / 240d), + "dooropenrange" => ExpressionValue.Number( + _navigationSettings.DoorOpenRangeMeters / 240d), + "doorlockpickdiffexcessthreshold" => ExpressionValue.Number( + _navigationSettings.DoorLockpickExcessThreshold), + "navpriorityboost" => ExpressionValue.Boolean( + _navigationSettings.Priority), + "followaroundcorners" => ExpressionValue.Boolean( + _navigationSettings.FollowAroundCorners), + "autofellowmanagement" => ExpressionValue.Boolean( + _combatSettings.AutoFellowManagement), + "enablestack" or "enableautostack" => + ExpressionValue.Boolean(_inventorySettings.AutoStack), + "autostack" => ExpressionValue.Boolean(_inventorySettings.AutoStack), + "enablecram" or "enableautocram" => + ExpressionValue.Boolean(_inventorySettings.AutoCram), + "autocram" => ExpressionValue.Boolean(_inventorySettings.AutoCram), + "autocraftitems" => ExpressionValue.Boolean(_inventorySettings.AutoCraftItems), + "splitpeas" => ExpressionValue.Boolean(_inventorySettings.SplitPeas), + "spellcompmin-critical" => ExpressionValue.Number( + _inventorySettings.CriticalComponentMinimum), + "spellcompmin-normal" => ExpressionValue.Number( + _inventorySettings.NormalComponentMinimum), + "spellcompmin-idle" => ExpressionValue.Number( + _inventorySettings.IdleComponentMinimum), + "idlecraftcount_healthkits" or "idlecraftcount-healthkits" => + ExpressionValue.Number( + _inventorySettings.IdleHealthKitCount), + "idlecraftcount_stamkits" or "idlecraftcount-stamkits" => + ExpressionValue.Number( + _inventorySettings.IdleStaminaKitCount), + "idlecraftcount_manakits" or "idlecraftcount-manakits" => + ExpressionValue.Number( + _inventorySettings.IdleManaKitCount), + "idlecraftcount_healthfood" or "idlecraftcount-healthfood" => + ExpressionValue.Number( + _inventorySettings.IdleHealthFoodCount), + "idlecraftcount_stamfood" or "idlecraftcount-stamfood" => + ExpressionValue.Number( + _inventorySettings.IdleStaminaFoodCount), + "idlecraftcount_manafood" or "idlecraftcount-manafood" => + ExpressionValue.Number( + _inventorySettings.IdleManaFoodCount), + "refillwornmana" => ExpressionValue.Boolean( + _inventorySettings.RefillWornMana), + "manachargeswhenoff" => ExpressionValue.Boolean( + _inventorySettings.ManaChargesWhenOff), + "refillwornmana-item-manapercent" => ExpressionValue.Number( + _inventorySettings.RefillWornManaPercent), + "readunknownscrolls" => ExpressionValue.Boolean( + _inventorySettings.Loot.ReadUnknownScrolls), + "lootallcorpses" => ExpressionValue.Boolean( + _inventorySettings.Loot.LootAllCorpses), + "lootfellowcorpses" => ExpressionValue.Boolean( + _inventorySettings.Loot.LootFellowCorpses), + "lootpriorityboost" => ExpressionValue.Boolean( + _inventorySettings.Loot.PriorityBoost), + "lootonlyrarecorpses" => ExpressionValue.Boolean( + _inventorySettings.Loot.LootOnlyRareCorpses), + "combinesalvage" => ExpressionValue.Boolean( + _inventorySettings.Loot.CombineSalvage), + "manastonelootcount" => ExpressionValue.Number( + _inventorySettings.Loot.ManaStoneLootCount), + "manatankminimummana" => ExpressionValue.Number( + _inventorySettings.Loot.ManaTankMinimumMana), + "corpsecachetimeoutminutes" => ExpressionValue.Number( + _inventorySettings.Loot.CorpseCacheTimeoutMinutes), + "corpseitemappearancetimeoutseconds" => ExpressionValue.Number( + _inventorySettings.Loot.CorpseItemAppearanceTimeoutSeconds), + "corpseitemidtimeoutseconds" => ExpressionValue.Number( + _inventorySettings.Loot.CorpseItemIdentifyTimeoutSeconds), + "corpseopentimeoutseconds" => ExpressionValue.Number( + _inventorySettings.Loot.CorpseOpenTimeoutSeconds), + "blacklistcorpseopenattemptcount" => ExpressionValue.Number( + _inventorySettings.Loot.BlacklistCorpseOpenAttemptCount), + "blacklistcorpseopentimeoutseconds" => ExpressionValue.Number( + _inventorySettings.Loot.BlacklistCorpseOpenTimeoutSeconds), + "corpselootitemmaxattempts" => ExpressionValue.Number( + _inventorySettings.Loot.CorpseLootItemMaxAttempts), + "minimumhealkitsuccesschance" => ExpressionValue.Number( + _vitalSettings.MinimumHealKitSuccessChance), + "usehealersheart" => ExpressionValue.Boolean( + _vitalSettings.UseHealersHeart), + "rechargeboosttimeseconds" => ExpressionValue.Number( + _vitalSettings.RechargeBoostTimeSeconds), + "rechargeboostamount" => ExpressionValue.Number( + _vitalSettings.RechargeBoostAmount), + "clearlevelboostflagoncast" => ExpressionValue.Boolean( + _vitalSettings.ClearLevelBoostFlagOnCast), + "whoyougonnacall" => ExpressionValue.Boolean( + _combatSettings.WhoYouGonnaCall), + "castdispelself" => ExpressionValue.Boolean( + _vitalSettings.CastDispelSelf), + "usedispelitems" => ExpressionValue.Boolean( + _vitalSettings.UseDispelItems), + "usedispeldrum" => ExpressionValue.Boolean( + _vitalSettings.UseDispelDrum), + "usekitsinmagicmode" => ExpressionValue.Boolean( + _vitalSettings.UseKitsInMagicMode), + "gotopeacemodetousekits" => ExpressionValue.Boolean( + _vitalSettings.GoToPeaceModeToUseKits), + "staminatohealthmultiplier" => ExpressionValue.Number( + _vitalSettings.StaminaToHealthMultiplier), + "manatohealthmultiplier" => ExpressionValue.Number( + _vitalSettings.ManaToHealthMultiplier), + "recharge-norm-hitp" => ExpressionValue.Number( + _vitalSettings.NormalHealth * 100d), + "recharge-norm-stam" => ExpressionValue.Number( + _vitalSettings.NormalStamina * 100d), + "recharge-norm-mana" => ExpressionValue.Number( + _vitalSettings.NormalMana * 100d), + "recharge-notarg-hitp" => ExpressionValue.Number( + _vitalSettings.NoTargetHealth * 100d), + "recharge-notarg-stam" => ExpressionValue.Number( + _vitalSettings.NoTargetStamina * 100d), + "recharge-notarg-mana" => ExpressionValue.Number( + _vitalSettings.NoTargetMana * 100d), + "recharge-helper-hitp" => ExpressionValue.Number( + _vitalSettings.HelperHealth * 100d), + "recharge-helper-stam" => ExpressionValue.Number( + _vitalSettings.HelperStamina * 100d), + "recharge-helper-mana" => ExpressionValue.Number( + _vitalSettings.HelperMana * 100d), + "rebufftimeremainingseconds" => ExpressionValue.Number( + _buffSettings.RebuffWhenUnderSeconds), + "buffcastrecast_seconds" => ExpressionValue.Number( + _buffSettings.BuffCastRecastSeconds), + "buffcastrecastreset_seconds" => ExpressionValue.Number( + _buffSettings.BuffCastRecastResetSeconds), + "blacklistedspellcomps" => ExpressionValue.String( + _buffSettings.BlacklistedSpellComponents), + "droptopeacemoderetrycount" => ExpressionValue.Number( + _vitalSettings.DropToPeaceModeRetryCount), + "fastcastbuffs" => ExpressionValue.Boolean( + _buffSettings.FastCastBuffs), + "usebreakableturnto" => ExpressionValue.Boolean( + _combatSettings.UseBreakableTurnTo), + "useprojectileawareness" => ExpressionValue.Boolean( + _combatSettings.UseProjectileAwareness), + "collisionprojectileradius" => ExpressionValue.Number( + _combatSettings.CollisionProjectileRadius), + "collisionstepdistance" => ExpressionValue.Number( + _combatSettings.CollisionStepDistance), + "showcollisiondebug" => ExpressionValue.Boolean( + _combatSettings.ShowCollisionDebug), + "maximumcollisioncheckspertick" => ExpressionValue.Number( + _combatSettings.MaximumCollisionChecksPerTick), + "usespecialammo" => ExpressionValue.Number( + _combatSettings.UseSpecialAmmo), + "spellrangefudge" => ExpressionValue.Number( + _combatSettings.SpellRangeFudge), + "buffwithuntrained-item" => ExpressionValue.Number( + _buffSettings.BuffWithUntrainedItemSkill), + "buffwithuntrained-creature" => ExpressionValue.Number( + _buffSettings.BuffWithUntrainedCreatureSkill), + "buffwithuntrained-life" => ExpressionValue.Number( + _buffSettings.BuffWithUntrainedLifeSkill), + "allowdebufffallback" => ExpressionValue.Boolean( + _combatSettings.AllowDebuffFallback), + "rechargehandlerset" => ExpressionValue.String( + _vitalSettings.RechargeHandlerSet), + _ => _combatSettings.DynamicSettings.TryGetValue(key, out MonsterValue value) + ? ToExpressionValue(value) + : ToExpressionValue(VtankOptionCatalog.Default(key)), + }; + } + + private static ExpressionValue ToExpressionValue(MonsterValue value) => + value.Kind switch + { + MonsterValueKind.Number => ExpressionValue.Number(value.Number), + MonsterValueKind.Boolean => ExpressionValue.Boolean(value.Boolean), + _ => ExpressionValue.String(value.Text), + }; + + private double GetDynamicNumber(string name, double fallback) => + _combatSettings.DynamicSettings.TryGetValue(name, out MonsterValue value) + && value.Kind == MonsterValueKind.Number + ? value.Number + : fallback; + + private void RegisterVtankExpressionFunctions() + { + ExpressionFunctionRegistry functions = _expressions.Registry; + functions.Register("vtsetmetastate", 1, 1, (_, args) => + { + _meta.Transition(args[0].AsString("vtsetmetastate")); + _combatSettings.MetaState = _meta.CurrentState; + return ExpressionValue.One; + }, "vtsetmetastate[state]"); + functions.Register("vtgetmetastate", 0, 0, (_, _) => + ExpressionValue.String(_meta.CurrentState), "vtgetmetastate[]"); + functions.Register("vtgetmeta", 0, 0, (_, _) => + ExpressionValue.String(_metaProfiles.Selected), "vtgetmeta[]"); + functions.Register("vtsetsetting", 2, 2, (_, args) => + { + string name = args[0].AsString("vtsetsetting"); + ExpressionValue value = args[1]; + if (value.Kind == ExpressionValueKind.String + && double.TryParse( + value.AsString(), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out double number)) + { + value = ExpressionValue.Number(number); + } + return ExpressionValue.Boolean(SetMetaOption(name, value)); + }, "vtsetsetting[setting,value]"); + functions.Register("vtgetsetting", 1, 1, (_, args) => + ExpressionValue.String(GetMetaOption( + args[0].AsString("vtgetsetting")).ToDisplayString()), + "vtgetsetting[setting]"); + functions.Register("uboptset", 2, 2, (_, args) => + ExpressionValue.Boolean(SetMetaOption( + args[0].AsString("uboptset"), + args[1])), + "uboptset[setting,value]"); + functions.Register("uboptget", 1, 1, (_, args) => + GetMetaOption(args[0].AsString("uboptget")), + "uboptget[setting]"); + functions.Register("actiontrygiveprofile", 2, 2, (_, args) => + ExpressionValue.Boolean(_profileGive.TryStart( + args[0].AsString("actiontrygiveprofile"), + args[1].AsString("actiontrygiveprofile"))), + "actiontrygiveprofile[lootprofile,target]"); + functions.Register("vtmacroenabled", 0, 0, (_, _) => + ExpressionValue.Boolean(_combat.Enabled), + "vtmacroenabled[]"); + } + + private bool SetMetaOption(string name, ExpressionValue value) + { + string canonical = VtankOptionCatalog.IsKnown(name) + ? VtankOptionCatalog.Canonical(name) + : name.Trim(); + string key = canonical.ToLowerInvariant(); + switch (key) + { + case "enablebuffing": + _buffSettings.Enabled = value.IsTruthy; + break; + case "enablecombat": + _combatSettings.Enabled = value.IsTruthy; + break; + case "enablenav": + case "enablenavigation": + case "enableautonavigator": + _navigationSettings.Enabled = value.IsTruthy; + break; + case "enablelooting": + _inventorySettings.Loot.Enabled = value.IsTruthy; + break; + case "enablemeta": + _meta.SetEnabled(value.IsTruthy); + break; + case "spelldiffexcessthreshold-hunt": + _combatSettings.HuntSkillExcessOverDifficulty = Math.Clamp( + value.AsInt32("SpellDiffExcessThreshold-Hunt"), -100, 500); + break; + case "arrowheadfletchdiffexcessthreshold": + _inventorySettings.ArrowheadFletchDifficultyExcess = Math.Clamp( + value.AsInt32("ArrowheadFletchDiffExcessThreshold"), + -100, + 500); + break; + case "dohelp": + _vitalSettings.HelpOthers = value.IsTruthy; + break; + case "monsterrange": + _combatSettings.MaximumRange = Math.Clamp( + checked((float)value.AsNumber("MonsterRange")), 1f, 100f); + break; + case "attackdistance": + _combatSettings.MaximumRange = Math.Clamp( + checked((float)(value.AsNumber("AttackDistance") * 240d)), + 1f, + 100f); + break; + case "attackminimumdistance": + _combatSettings.MinimumRange = Math.Clamp( + checked((float)(value.AsNumber("AttackMinimumDistance") * 240d)), + 0f, + 100f); + break; + case "approachdistance": + _combatSettings.ApproachDistance = Math.Clamp( + checked((float)(value.AsNumber("ApproachDistance") * 240d)), + 0f, + 100f); + break; + case "ringdistance": + _combatSettings.RingDistance = Math.Clamp( + checked((float)(value.AsNumber("RingDistance") * 240d)), + 1f, + 100f); + break; + case "arcrange": + _combatSettings.ArcRange = Math.Clamp( + checked((float)(value.AsNumber("ArcRange") * 240d)), + 1f, + 100f); + break; + case "targetselectanglerange": + _combatSettings.TargetSelectAngleRange = Math.Clamp( + checked((float)(value.AsNumber("TargetSelectAngleRange") * 240d)), + 1f, + 100f); + break; + case "corpseapproachrange-max": + _inventorySettings.Loot.CorpseApproachRange = Math.Clamp( + checked((float)(value.AsNumber("CorpseApproachRange-Max") * 240d)), + 1f, + 100f); + break; + case "corpseapproachrange-min": + _inventorySettings.Loot.CorpseMinimumApproachRange = Math.Clamp( + checked((float)(value.AsNumber("CorpseApproachRange-Min") * 240d)), + 0f, + 100f); + break; + case "navclosestoprange": + _navigationSettings.MinimumDistanceMeters = Math.Clamp( + value.AsNumber("NavCloseStopRange") * 240d, + 0.5d, + 50d); + break; + case "navfarstoprange": + _navigationSettings.MaximumDistanceMeters = Math.Clamp( + value.AsNumber("NavFarStopRange") * 240d, + _navigationSettings.MinimumDistanceMeters, + 240_000_000d); + break; + case "useportaldistance": + _navigationSettings.PortalUseDistanceMeters = Math.Clamp( + value.AsNumber("UsePortalDistance") * 240d, + 0.5d, + 50d); + break; + case "helperdistancehitp": + _vitalSettings.HelperHealthDistance = Math.Clamp( + checked((float)(value.AsNumber("HelperDistanceHitP") * 240d)), + 1f, + 100f); + break; + case "helperdistancestam": + _vitalSettings.HelperStaminaDistance = Math.Clamp( + checked((float)(value.AsNumber("HelperDistanceStam") * 240d)), + 1f, + 100f); + break; + case "helperdistancemana": + _vitalSettings.HelperManaDistance = Math.Clamp( + checked((float)(value.AsNumber("HelperDistanceMana") * 240d)), + 1f, + 100f); + break; + case "minimumringtargets": + _combatSettings.MinimumRingTargets = Math.Clamp( + value.AsInt32("MinimumRingTargets"), 1, 25); + break; + case "defaultmeleeattackheight": + _combatSettings.AttackHeight = (PluginAttackHeight)Math.Clamp( + value.AsInt32("DefaultMeleeAttackHeight"), 1, 3); + break; + case "defaultmeleeattackpower": + case "attackpower": + _combatSettings.AttackPower = Math.Clamp( + checked((float)value.AsNumber("AttackPower")), 0f, 1f); + break; + case "targetlock": + _combatSettings.TargetLock = value.IsTruthy; + break; + case "idlepeacemode": + _combatSettings.IdlePeaceMode = value.IsTruthy; + break; + case "stopmacroondeath": + _combatSettings.StopMacroOnDeath = value.IsTruthy; + break; + case "jumpoutwandcasting": + _combatSettings.JumpOutWandCasting = value.IsTruthy; + break; + case "dojiggle": + _combatSettings.DoJiggle = value.IsTruthy; + break; + case "randomhelperbuffs": + _buffSettings.RandomHelperBuffs = value.IsTruthy; + break; + case "randomhelperintervalseconds": + _buffSettings.RandomHelperIntervalSeconds = Math.Clamp( + value.AsNumber("RandomHelperIntervalSeconds"), 0.25d, 3600d); + break; + case "idlebufftopoff": + _buffSettings.IdleBuffTopoff = value.IsTruthy; + break; + case "idlebufftopofftimeseconds": + _buffSettings.IdleBuffTopoffSeconds = Math.Clamp( + value.AsNumber("IdleBuffTopoffTimeSeconds"), + 30d, + 7200d); + break; + case "buffprofile-prots": + _buffSettings.ProtectionElements = NormalizeElementProfile( + value.ToDisplayString()); + break; + case "buffprofile-banes": + _buffSettings.BaneElements = NormalizeElementProfile( + value.ToDisplayString()); + break; + case "buffprofile_prots": + _buffSettings.ProtectionProfileMode = Math.Clamp( + value.AsInt32("BuffProfile_Prots"), 1, 8); + break; + case "buffprofile_banes": + _buffSettings.BaneProfileMode = Math.Clamp( + value.AsInt32("BuffProfile_Banes"), 1, 8); + break; + case "targetselectmethod": + _combatSettings.SelectionMethod = (TargetSelectionMethod)Math.Clamp( + value.AsInt32("TargetSelectMethod") - 1, 0, 2); + break; + case "autoattackpower": + _combatSettings.AutoAttackPower = value.IsTruthy; + break; + case "userecklessness": + _combatSettings.UseRecklessness = value.IsTruthy; + break; + case "debuffeachfirst": + _combatSettings.DebuffEachFirst = (DebuffEachFirst)Math.Clamp( + value.AsInt32("DebuffEachFirst"), 1, 3); + break; + case "debuffselectionmethod": + _combatSettings.DebuffSelectionMethod = + (DebuffSelectionMethod)Math.Clamp( + value.AsInt32("DebuffSelectionMethod"), 1, 2); + break; + case "debuffprecastseconds": + _combatSettings.DebuffPrecastSeconds = Math.Clamp( + value.AsNumber("DebuffPrecastSeconds"), 0d, 60d); + break; + case "switchwandstodebuff": + _combatSettings.SwitchWandsToDebuff = value.IsTruthy; + break; + case "usearcs": + _combatSettings.UseArcs = value.IsTruthy; + break; + case "deleteghostmonsters": + _combatSettings.DeleteGhostMonsters = value.IsTruthy; + break; + case "ghostmonsterspellattemptcount": + _combatSettings.GhostMonsterSpellAttemptCount = Math.Clamp( + value.AsInt32("GhostMonsterSpellAttemptCount"), 1, 1000); + break; + case "blacklistmonsterattemptcount": + _combatSettings.BlacklistMonsterAttemptCount = Math.Clamp( + value.AsInt32("BlacklistMonsterAttemptCount"), 1, 20); + break; + case "blacklistmonstertimeoutseconds": + _combatSettings.BlacklistMonsterTimeoutSeconds = Math.Clamp( + value.AsNumber("BlacklistMonsterTimeoutSeconds"), 1d, 3600d); + break; + case "deleteghostmonstersbyhptracker": + _combatSettings.DeleteGhostMonstersByHealthTracker = value.IsTruthy; + break; + case "ghostdeletehptrackerseconds": + _combatSettings.GhostDeleteHealthTrackerSeconds = Math.Clamp( + value.AsNumber("GhostDeleteHPTrackerSeconds"), 1d, 300d); + break; + case "summonpets": + _combatSettings.SummonPets = value.IsTruthy; + break; + case "petrangemode": + _combatSettings.PetRangeMode = (PetRangeMode)Math.Clamp( + value.AsInt32("PetRangeMode"), 0, 1); + break; + case "petcustomrange": + _combatSettings.PetCustomRange = Math.Clamp( + checked((float)(value.AsNumber("PetCustomRange") * 240d)), + 1f, + 100f); + break; + case "petmonsterdensity": + _combatSettings.PetMonsterDensity = Math.Clamp( + value.AsInt32("PetMonsterDensity"), 1, 25); + break; + case "petrefillcount-idle": + _combatSettings.PetRefillCountIdle = Math.Clamp( + value.AsInt32("PetRefillCount-Idle"), 0, 3); + break; + case "petrefillcount-normal": + _combatSettings.PetRefillCountNormal = Math.Clamp( + value.AsInt32("PetRefillCount-Normal"), 0, 3); + break; + case "openapproachdoors": + case "opendoors": + _navigationSettings.OpenDoors = value.IsTruthy; + break; + case "dooridrange": + _navigationSettings.DoorIdentifyRangeMeters = Math.Clamp( + value.AsNumber("DoorIDRange") * 240d, 1d, 100d); + break; + case "dooropenrange": + _navigationSettings.DoorOpenRangeMeters = Math.Clamp( + value.AsNumber("DoorOpenRange") * 240d, + 0.5d, + _navigationSettings.DoorIdentifyRangeMeters); + break; + case "doorlockpickdiffexcessthreshold": + _navigationSettings.DoorLockpickExcessThreshold = Math.Clamp( + value.AsInt32("DoorLockpickDiffExcessThreshold"), -500, 500); + break; + case "navpriorityboost": + _navigationSettings.Priority = value.IsTruthy; + break; + case "followaroundcorners": + _navigationSettings.FollowAroundCorners = value.IsTruthy; + break; + case "autofellowmanagement": + _combatSettings.AutoFellowManagement = value.IsTruthy; + break; + case "enablestack": + case "enableautostack": + case "autostack": + _inventorySettings.AutoStack = value.IsTruthy; + break; + case "enablecram": + case "enableautocram": + case "autocram": + _inventorySettings.AutoCram = value.IsTruthy; + break; + case "autocraftitems": + _inventorySettings.AutoCraftItems = value.IsTruthy; + break; + case "splitpeas": + _inventorySettings.SplitPeas = value.IsTruthy; + break; + case "spellcompmin-critical": + _inventorySettings.CriticalComponentMinimum = Math.Clamp( + value.AsInt32("SpellCompMin-Critical"), 0, 1000); + break; + case "spellcompmin-normal": + _inventorySettings.NormalComponentMinimum = Math.Clamp( + value.AsInt32("SpellCompMin-Normal"), 0, 1000); + break; + case "spellcompmin-idle": + _inventorySettings.IdleComponentMinimum = Math.Clamp( + value.AsInt32("SpellCompMin-Idle"), 0, 1000); + break; + case "idlecraftcount_healthkits": + case "idlecraftcount-healthkits": + _inventorySettings.IdleHealthKitCount = Math.Clamp( + value.AsInt32("IdleCraftCount_HealthKits"), 0, 1000); + break; + case "idlecraftcount_stamkits": + case "idlecraftcount-stamkits": + _inventorySettings.IdleStaminaKitCount = Math.Clamp( + value.AsInt32("IdleCraftCount_StamKits"), 0, 1000); + break; + case "idlecraftcount_manakits": + case "idlecraftcount-manakits": + _inventorySettings.IdleManaKitCount = Math.Clamp( + value.AsInt32("IdleCraftCount_ManaKits"), 0, 1000); + break; + case "idlecraftcount_healthfood": + case "idlecraftcount-healthfood": + _inventorySettings.IdleHealthFoodCount = Math.Clamp( + value.AsInt32("IdleCraftCount_HealthFood"), 0, 1000); + break; + case "idlecraftcount_stamfood": + case "idlecraftcount-stamfood": + _inventorySettings.IdleStaminaFoodCount = Math.Clamp( + value.AsInt32("IdleCraftCount_StamFood"), 0, 1000); + break; + case "idlecraftcount_manafood": + case "idlecraftcount-manafood": + _inventorySettings.IdleManaFoodCount = Math.Clamp( + value.AsInt32("IdleCraftCount_ManaFood"), 0, 1000); + break; + case "refillwornmana": + _inventorySettings.RefillWornMana = value.IsTruthy; + break; + case "manachargeswhenoff": + _inventorySettings.ManaChargesWhenOff = value.IsTruthy; + break; + case "refillwornmana-item-manapercent": + _inventorySettings.RefillWornManaPercent = Math.Clamp( + value.AsInt32("RefillWornMana-Item-ManaPercent"), 0, 100); + break; + case "readunknownscrolls": + _inventorySettings.Loot.ReadUnknownScrolls = value.IsTruthy; + break; + case "lootallcorpses": + _inventorySettings.Loot.LootAllCorpses = value.IsTruthy; + break; + case "lootfellowcorpses": + _inventorySettings.Loot.LootFellowCorpses = value.IsTruthy; + break; + case "lootpriorityboost": + _inventorySettings.Loot.PriorityBoost = value.IsTruthy; + break; + case "lootonlyrarecorpses": + _inventorySettings.Loot.LootOnlyRareCorpses = value.IsTruthy; + break; + case "combinesalvage": + _inventorySettings.Loot.CombineSalvage = value.IsTruthy; + break; + case "manastonelootcount": + _inventorySettings.Loot.ManaStoneLootCount = Math.Clamp( + value.AsInt32("ManaStoneLootCount"), 0, 1000); + break; + case "manatankminimummana": + _inventorySettings.Loot.ManaTankMinimumMana = Math.Clamp( + value.AsInt32("ManaTankMinimumMana"), 1, int.MaxValue); + break; + case "corpsecachetimeoutminutes": + _inventorySettings.Loot.CorpseCacheTimeoutMinutes = Math.Clamp( + value.AsNumber("CorpseCacheTimeoutMinutes"), 1d, 1440d); + break; + case "corpseitemappearancetimeoutseconds": + _inventorySettings.Loot.CorpseItemAppearanceTimeoutSeconds = + Math.Clamp( + value.AsNumber("CorpseItemAppearanceTimeoutSeconds"), + 0d, + 300d); + break; + case "corpseitemidtimeoutseconds": + _inventorySettings.Loot.CorpseItemIdentifyTimeoutSeconds = + Math.Clamp( + value.AsNumber("CorpseItemIDTimeoutSeconds"), + 1d, + 600d); + break; + case "corpseopentimeoutseconds": + _inventorySettings.Loot.CorpseOpenTimeoutSeconds = Math.Clamp( + value.AsNumber("CorpseOpenTimeoutSeconds"), 0.1d, 60d); + break; + case "blacklistcorpseopenattemptcount": + _inventorySettings.Loot.BlacklistCorpseOpenAttemptCount = Math.Clamp( + value.AsInt32("BlacklistCorpseOpenAttemptCount"), 1, 1000); + break; + case "blacklistcorpseopentimeoutseconds": + _inventorySettings.Loot.BlacklistCorpseOpenTimeoutSeconds = Math.Clamp( + value.AsNumber("BlacklistCorpseOpenTimeoutSeconds"), 1d, 3600d); + break; + case "corpselootitemmaxattempts": + _inventorySettings.Loot.CorpseLootItemMaxAttempts = Math.Clamp( + value.AsInt32("CorpseLootItemMaxAttempts"), 1, 1000); + break; + case "minimumhealkitsuccesschance": + _vitalSettings.MinimumHealKitSuccessChance = Math.Clamp( + value.AsInt32("MinimumHealKitSuccessChance"), 0, 100); + break; + case "usehealersheart": + _vitalSettings.UseHealersHeart = value.IsTruthy; + _vitalRecharge.Reset(); + break; + case "rechargeboosttimeseconds": + _vitalSettings.RechargeBoostTimeSeconds = Math.Clamp( + value.AsNumber("RechargeBoostTimeSeconds"), 0d, 300d); + break; + case "rechargeboostamount": + _vitalSettings.RechargeBoostAmount = Math.Clamp( + value.AsInt32("RechargeBoostAmount"), 0, 1000); + break; + case "clearlevelboostflagoncast": + _vitalSettings.ClearLevelBoostFlagOnCast = value.IsTruthy; + break; + case "whoyougonnacall": + _combatSettings.WhoYouGonnaCall = value.IsTruthy; + break; + case "castdispelself": + _vitalSettings.CastDispelSelf = value.IsTruthy; + _dispel.Reset(); + break; + case "usedispelitems": + _vitalSettings.UseDispelItems = value.IsTruthy; + _dispel.Reset(); + break; + case "usedispeldrum": + _vitalSettings.UseDispelDrum = value.IsTruthy; + _dispel.Reset(); + break; + case "usekitsinmagicmode": + _vitalSettings.UseKitsInMagicMode = value.IsTruthy; + break; + case "gotopeacemodetousekits": + _vitalSettings.GoToPeaceModeToUseKits = value.IsTruthy; + break; + case "staminatohealthmultiplier": + _vitalSettings.StaminaToHealthMultiplier = Math.Clamp( + value.AsNumber("StaminaToHealthMultiplier"), 0d, 10d); + break; + case "manatohealthmultiplier": + _vitalSettings.ManaToHealthMultiplier = Math.Clamp( + value.AsNumber("ManaToHealthMultiplier"), 0d, 10d); + break; + case "recharge-norm-hitp": + _vitalSettings.NormalHealth = Math.Clamp( + value.AsNumber("Recharge-Norm-HitP") / 100d, 0d, 1d); + break; + case "recharge-norm-stam": + _vitalSettings.NormalStamina = Math.Clamp( + value.AsNumber("Recharge-Norm-Stam") / 100d, 0d, 1d); + break; + case "recharge-norm-mana": + _vitalSettings.NormalMana = Math.Clamp( + value.AsNumber("Recharge-Norm-Mana") / 100d, 0d, 1d); + break; + case "recharge-notarg-hitp": + _vitalSettings.NoTargetHealth = Math.Clamp( + value.AsNumber("Recharge-NoTarg-HitP") / 100d, 0d, 1d); + break; + case "recharge-notarg-stam": + _vitalSettings.NoTargetStamina = Math.Clamp( + value.AsNumber("Recharge-NoTarg-Stam") / 100d, 0d, 1d); + break; + case "recharge-notarg-mana": + _vitalSettings.NoTargetMana = Math.Clamp( + value.AsNumber("Recharge-NoTarg-Mana") / 100d, 0d, 1d); + break; + case "recharge-helper-hitp": + _vitalSettings.HelperHealth = Math.Clamp( + value.AsNumber("Recharge-Helper-HitP") / 100d, 0d, 1d); + break; + case "recharge-helper-stam": + _vitalSettings.HelperStamina = Math.Clamp( + value.AsNumber("Recharge-Helper-Stam") / 100d, 0d, 1d); + break; + case "recharge-helper-mana": + _vitalSettings.HelperMana = Math.Clamp( + value.AsNumber("Recharge-Helper-Mana") / 100d, 0d, 1d); + break; + case "spelldiffexcessthreshold-buff": + _buffSettings.SkillExcessOverDifficulty = Math.Clamp( + value.AsInt32("SpellDiffExcessThreshold-Buff"), -100, 100); + break; + case "rebufftimeremainingseconds": + _buffSettings.RebuffWhenUnderSeconds = Math.Clamp( + value.AsNumber("RebuffTimeRemainingSeconds"), 0d, 3600d); + break; + case "buffcastrecast_seconds": + _buffSettings.BuffCastRecastSeconds = Math.Clamp( + value.AsNumber("BuffCastRecast_Seconds"), 0d, 3600d); + break; + case "buffcastrecastreset_seconds": + _buffSettings.BuffCastRecastResetSeconds = Math.Clamp( + value.AsNumber("BuffCastRecastReset_Seconds"), 0d, 3600d); + break; + case "blacklistedspellcomps": + _buffSettings.BlacklistedSpellComponents = + value.ToDisplayString(); + _combatSettings.BlacklistedSpellComponents = + _buffSettings.BlacklistedSpellComponents; + break; + case "droptopeacemoderetrycount": + _vitalSettings.DropToPeaceModeRetryCount = Math.Clamp( + value.AsInt32("DropToPeaceModeRetryCount"), 1, 1000); + break; + case "fastcastbuffs": + _buffSettings.FastCastBuffs = value.IsTruthy; + break; + case "usebreakableturnto": + _combatSettings.UseBreakableTurnTo = value.IsTruthy; + break; + case "useprojectileawareness": + _combatSettings.UseProjectileAwareness = value.IsTruthy; + break; + case "collisionprojectileradius": + _combatSettings.CollisionProjectileRadius = Math.Clamp( + checked((float)value.AsNumber("CollisionProjectileRadius")), + 0f, + 10f); + break; + case "collisionstepdistance": + _combatSettings.CollisionStepDistance = Math.Clamp( + checked((float)value.AsNumber("CollisionStepDistance")), + 0.01f, + 10f); + break; + case "showcollisiondebug": + _combatSettings.ShowCollisionDebug = value.IsTruthy; + break; + case "maximumcollisioncheckspertick": + _combatSettings.MaximumCollisionChecksPerTick = Math.Clamp( + value.AsInt32("MaximumCollisionChecksPerTick"), 1, 100_000); + break; + case "usespecialammo": + _combatSettings.UseSpecialAmmo = Math.Clamp( + value.AsInt32("UseSpecialAmmo"), 0, 3); + break; + case "spellrangefudge": + _combatSettings.SpellRangeFudge = Math.Clamp( + checked((float)value.AsNumber("SpellRangeFudge")), + 0f, + 75f); + break; + case "buffwithuntrained-item": + _buffSettings.BuffWithUntrainedItemSkill = Math.Clamp( + value.AsInt32("BuffWithUntrained-Item"), 0, 275); + break; + case "buffwithuntrained-creature": + _buffSettings.BuffWithUntrainedCreatureSkill = Math.Clamp( + value.AsInt32("BuffWithUntrained-Creature"), 0, 275); + break; + case "buffwithuntrained-life": + _buffSettings.BuffWithUntrainedLifeSkill = Math.Clamp( + value.AsInt32("BuffWithUntrained-Life"), 0, 275); + break; + case "allowdebufffallback": + _combatSettings.AllowDebuffFallback = value.IsTruthy; + break; + case "rechargehandlerset": + _vitalSettings.RechargeHandlerSet = value.ToDisplayString(); + break; + default: + break; + } + _combatSettings.DynamicSettings[canonical] = ToMonsterValue(value); + if (!_applyingProfileOptions) + SaveProfile(); + return true; + } + + private static MonsterValue ToMonsterValue(ExpressionValue value) => + value.Kind switch + { + ExpressionValueKind.Boolean => MonsterValue.FromBoolean(value.IsTruthy), + ExpressionValueKind.Number => MonsterValue.FromNumber(value.AsNumber()), + _ => MonsterValue.FromText(value.ToDisplayString()), + }; + + private static string NormalizeElementProfile(string value) + { + const string order = "ALFCBPS"; + var result = new StringBuilder(order.Length); + foreach (char element in order) + { + if (value.IndexOf(element, StringComparison.OrdinalIgnoreCase) >= 0) + result.Append(element); + } + return result.ToString(); + } + + private void SelectProfile(string name) + { + SaveProfile(); + if (!_profiles.Select(name)) + { + _profileLifecycleNotice = $"Profile '{name}' is unavailable."; + return; + } + LoadSelectedProfile(); + _profileLifecycleNotice = $"Loaded {_profiles.Selected}."; + } + + private void CreateProfileCore(bool copyCurrent) + { + if (!_profiles.Create( + _profileNameDraft, + copyCurrent, + _combatSettings, + _buffSettings, + _vitalSettings, + _inventorySettings, + _noBuffItemNames, + out string notice)) + { + _profileLifecycleNotice = notice; + return; + } + _profileNameDraft = string.Empty; + _profileLifecycleNotice = notice; + ResetProfileConsumers(); + } + + private void ClearProfileCore() + { + _profiles.ClearCurrent( + _combatSettings, + _buffSettings, + _vitalSettings, + _inventorySettings, + _noBuffItemNames); + _profileLifecycleNotice = $"Cleared {_profiles.Selected} to VTank defaults."; + ResetProfileConsumers(); + } + + private void LoadSelectedProfile() + { + _profiles.LoadCurrent( + _combatSettings, + _buffSettings, + _vitalSettings, + _inventorySettings, + _noBuffItemNames); + LoadLootProfile(); + LoadRouteProfile(); + ApplyPersistedOptionOverrides(); + ResetProfileConsumers(); + } + + private void ResetProfileConsumers() + { + _vitalRecharge.Reset(); + _dispel.Reset(); + _inventoryMaintenance.Reset(); + _crafting.Reset(); + _itemManaRecharge.Reset(); + _loot.Reset(); + _navigation.Reset(); + _coverageSpellSnapshot = null; + _coverageRefreshRemaining = 0d; + RefreshMonsterEditor(); + RefreshItemEditors(); + RefreshLootEditor(); + RefreshRouteEditor(); + } + + private void ClearMossTankActionLocks() + { + ClearFastCastMovement(); + _buffCastRecastRemaining = 0d; + _randomHelperRemaining = 0d; + _combat.ClearActionLocks(); + _vitalRecharge.Reset(); + _dispel.Reset(); + _inventoryMaintenance.Reset(); + _crafting.Reset(); + _itemManaRecharge.Reset(); + _loot.Reset(); + _profileGive.Reset(); + _navigation.ClearActionLocks(); + } + + private void FakeImperil() + { + uint target = _host.Selection.SelectedObjectId ?? 0u; + if (target == 0u + || !_host.Automation.Objects.TryGet(target, out PluginWorldObject value) + || value.ObjectClass != PluginObjectClass.Monster) + { + WriteVtank("Select a monster first."); + return; + } + _combat.RecordFakeImperil(target); + WriteVtank("Fake cast complete."); + } + + private void EnsureCharacterProfile() + { + string characterName = _host.Automation.Character.Name; + bool macroChanged = _profiles.BindCharacter(characterName); + bool lootChanged = _lootProfiles.BindCharacter(characterName); + bool routeChanged = _routeProfiles.BindCharacter(characterName); + bool metaChanged = _metaProfiles.BindCharacter(characterName); + if (!macroChanged && !lootChanged && !routeChanged && !metaChanged) + return; + if (macroChanged) + LoadSelectedProfile(); + else + { + if (lootChanged) + LoadLootProfile(); + if (routeChanged) + LoadRouteProfile(); + if (metaChanged) + LoadMetaProfile(); + } + if (macroChanged && metaChanged) + LoadMetaProfile(); + _profileLifecycleNotice = $"Loaded {_profiles.Selected} for " + + (_host.Automation.Character.Name.Length == 0 + ? "this character." + : _host.Automation.Character.Name + "."); + } + + private void SaveProfile() + { + _profiles.SaveCurrent( + _combatSettings, + _buffSettings, + _vitalSettings, + _inventorySettings, + _noBuffItemNames); + _lootProfiles.SaveCurrent( + _inventorySettings.Loot.Rules, + _inventorySettings.Loot); + SaveRouteProfile(); + SaveMetaProfile(); + } + + private void ApplyPersistedOptionOverrides() + { + KeyValuePair[] overrides = _combatSettings + .DynamicSettings + .Where(pair => VtankOptionCatalog.IsKnown(pair.Key)) + .ToArray(); + if (overrides.Length == 0) + return; + _applyingProfileOptions = true; + try + { + foreach ((string name, MonsterValue value) in overrides) + SetMetaOption(name, ToExpressionValue(value)); + } + finally + { + _applyingProfileOptions = false; + } + } + // ── the loop ────────────────────────────────────────────────────────── private void Announce(string text) => _host.Automation.Chat.PostSystemMessage($"[MossTank] {text}"); @@ -176,6 +3574,14 @@ internal sealed class MossTankPanel return; } + StartForceBuff(); + } + + private void StartForceBuff() + { + if (_running) + return; + IAutomationSurface automation = _host.Automation; if (!automation.IsAvailable) { @@ -183,26 +3589,78 @@ internal sealed class MossTankPanel return; } - // Always a force pass: the button is Virindi Tank's Force Buff, which - // recasts everything rather than only what has lapsed. - _queue = BuildPlan(automation, force: true); + StartBuffPass( + automation, + force: true, + rebuffWhenUnderSeconds: null, + announce: true); + } + + private void CancelForceBuffCore() + { + if (!_running || !_forcePass) + return; + Stop("Stopped."); + Announce("Stopped."); + } + + private void StartBuffPass( + IAutomationSurface automation, + bool force, + double? rebuffWhenUnderSeconds, + bool announce) + { + double? effectiveThreshold = rebuffWhenUnderSeconds; + if (!force && _buffCastRecastRemaining > 0d) + { + effectiveThreshold = (effectiveThreshold + ?? _buffSettings.RebuffWhenUnderSeconds) + + _buffSettings.BuffCastRecastSeconds; + } + _queue = BuildPlan( + automation, + force, + effectiveThreshold); _queueIndex = 0; _castThisPass = 0; _sinceProgress = 0; - _selectionBeforePass = _host.Selection.SelectedObjectId; + _forcePass = force; + _announceBuffPass = announce; + if (_queue.Count > 0) + { + _selectionBeforePass = _host.Selection.SelectedObjectId; + _buffCastRecastRemaining = Math.Max( + 0d, + _buffSettings.BuffCastRecastResetSeconds); + } _running = _queue.Count > 0; - _status = _queue.Count == 0 - ? "Nothing to buff." - : $"Force buffing 0/{_queue.Count}…"; - _host.Log.Info($"MossTank: force pass started, {_queue.Count} buff(s) queued"); - Announce(_queue.Count == 0 - ? "Nothing to buff — no known self-buffs match your skills." - : $"Force buffing — {_queue.Count} spell(s)."); + if (_queue.Count == 0) + { + if (force) + _status = "Nothing to buff."; + if (announce) + { + Announce( + "Nothing to buff — no known self-buffs match your skills."); + } + return; + } + + string kind = force ? "Force buffing" : "Buffing"; + _status = $"{kind} 0/{_queue.Count}…"; + _host.Log.Info( + $"MossTank: {(force ? "force" : "automatic")} pass started, " + + $"{_queue.Count} buff(s) queued"); + if (announce) + Announce($"{kind} — {_queue.Count} spell(s)."); } private void Stop(string status) { + ClearFastCastMovement(); _running = false; + _forcePass = false; + _announceBuffPass = false; _queue = new List(); _queueIndex = 0; _status = status; @@ -218,28 +3676,229 @@ internal sealed class MossTankPanel _selectionBeforePass = null; } - private List BuildPlan(IAutomationSurface automation, bool force) => - BuffPlan.Build( + private List BuildPlan( + IAutomationSurface automation, + bool force, + double? rebuffWhenUnderSeconds = null) + { + List plan = BuffPlan.Build( BuffProfile.Build(automation.Spells.KnownSelfBuffs), automation.Character.Skills, automation.Character.Attributes, automation.Character.ActiveEnchantments, _buffSettings, - force); + force, + rebuffWhenUnderSeconds, + automation.Character.Level); + plan.RemoveAll(spell => SpellComponentPolicy.UsesBlacklistedComponent( + automation.Spells, + spell, + _buffSettings.BlacklistedSpellComponents)); + return plan; + } - private Dictionary SkillLevels(IAutomationSurface automation) + private void ToggleMacro() => SetMacroRunning(!_combat.Enabled); + + private void SetMacroRunning(bool running) { - var levels = new Dictionary(); - foreach (PluginSkillInfo skill in automation.Character.Skills) - levels[skill.SkillId] = skill.Current; - return levels; + if (_combat.Enabled == running) + return; + _combat.Toggle(); + if (running || _combat.Enabled) + return; + + // A stopped VTank macro owns no movement or staged maintenance work. + // Worn-mana upkeep is intentionally not reset: VTank's + // ManaChargesWhenOff option permits that one controller to continue. + _vitalRecharge.Reset(); + _dispel.Reset(); + _inventoryMaintenance.Reset(); + _crafting.Reset(); + _loot.Reset(); + _profileGive.Reset(); + _navigation.Reset(); } /// Driven by on the host update thread. public void OnTick(double elapsedSeconds) { + bool automationAvailable = _host.Automation.IsAvailable; + if (!automationAvailable) + { + if (_automationWasAvailable) + HandleSessionEnded(); + _automationWasAvailable = false; + RefreshDisplayBindings(elapsedSeconds); + return; + } + if (!_automationWasAvailable) + { + _automationWasAvailable = true; + HandleSessionStarted(); + } + + ObserveFastCastMovement(elapsedSeconds); + _buffCastRecastRemaining = Math.Max( + 0d, + _buffCastRecastRemaining - Math.Max(0d, elapsedSeconds)); + EnsureCharacterProfile(); + ShowFirstRunGuidance(); + ObserveCommandPortalState(); + bool macroRunning = _combat.Enabled; + if (macroRunning + && _combatSettings.StopMacroOnDeath + && _host.Automation.IsAvailable + && _host.Automation.Character.MaxHealth > 0u + && _host.Automation.Character.CurrentHealth == 0u) + { + SetMacroRunning(false); + macroRunning = false; + Announce("Macro stopped because the character died."); + } + _fellowshipManager.Tick( + elapsedSeconds, + macroRunning && AutoFellowManagementEnabled); + if (macroRunning) + _meta.OnTick(elapsedSeconds); + _combatSettings.MetaState = _meta.CurrentState; RefreshDisplayBindings(elapsedSeconds); + bool commandJumpOwnsAction = TickCommandJump(elapsedSeconds); + bool giveOwnsAction = _profileGive.Tick( + elapsedSeconds, + canAct: !_running && !commandJumpOwnsAction); + bool criticalCraftOwnsAction = _crafting.TickCritical( + elapsedSeconds, + canAct: macroRunning + && !_running + && !commandJumpOwnsAction + && !giveOwnsAction); + bool vitalOwnsAction = _vitalRecharge.Tick( + elapsedSeconds, + (macroRunning || _running) + && !commandJumpOwnsAction + && !giveOwnsAction + && !criticalCraftOwnsAction, + noTarget: !_combat.HasTarget); + TickAutomaticBuffing( + elapsedSeconds, + macroRunning, + canAct: !commandJumpOwnsAction + && !giveOwnsAction + && !criticalCraftOwnsAction + && !vitalOwnsAction); + bool dispelOwnsAction = _dispel.Tick( + elapsedSeconds, + canAct: macroRunning + && !_running + && !commandJumpOwnsAction + && !giveOwnsAction + && !criticalCraftOwnsAction + && !vitalOwnsAction); + bool manaRechargeOwnsAction = _itemManaRecharge.Tick( + canAct: (macroRunning || _inventorySettings.ManaChargesWhenOff) + && !_running + && !commandJumpOwnsAction + && !giveOwnsAction + && !criticalCraftOwnsAction + && !vitalOwnsAction + && !dispelOwnsAction); + bool craftingOwnsAction = _crafting.Tick( + elapsedSeconds, + canAct: macroRunning + && !_running + && !commandJumpOwnsAction + && !giveOwnsAction + && !criticalCraftOwnsAction + && !vitalOwnsAction + && !dispelOwnsAction + && !manaRechargeOwnsAction); + bool idleCraftingOwnsAction = _crafting.TickIdle( + elapsedSeconds, + canAct: macroRunning + && !_running + && !commandJumpOwnsAction + && !giveOwnsAction + && !criticalCraftOwnsAction + && !vitalOwnsAction + && !dispelOwnsAction + && !manaRechargeOwnsAction + && !craftingOwnsAction + && !_combat.HasTarget); + bool lootOwnsAction = _loot.Tick( + elapsedSeconds, + canAct: macroRunning + && !_running + && !commandJumpOwnsAction + && !giveOwnsAction + && !criticalCraftOwnsAction + && !vitalOwnsAction + && !dispelOwnsAction + && !manaRechargeOwnsAction + && !craftingOwnsAction + && !idleCraftingOwnsAction + && (_inventorySettings.Loot.PriorityBoost + || !_combat.HasTarget)); + bool inventoryOwnsAction = _inventoryMaintenance.Tick( + elapsedSeconds, + canAct: macroRunning + && !_running + && !commandJumpOwnsAction + && !giveOwnsAction + && !criticalCraftOwnsAction + && !vitalOwnsAction + && !dispelOwnsAction + && !manaRechargeOwnsAction + && !lootOwnsAction + && !craftingOwnsAction + && !idleCraftingOwnsAction + && !_combat.HasTarget); + bool navigationOwnsAction = _navigation.Tick( + elapsedSeconds, + canAct: macroRunning + && !_running + && !commandJumpOwnsAction + && !giveOwnsAction + && !criticalCraftOwnsAction + && !vitalOwnsAction + && !dispelOwnsAction + && !manaRechargeOwnsAction + && !lootOwnsAction + && !craftingOwnsAction + && !idleCraftingOwnsAction + && !inventoryOwnsAction + && (_navigationSettings.Priority || !_combat.HasTarget)); + bool randomHelperOwnsAction = TickRandomHelper( + elapsedSeconds, + canAct: macroRunning + && !_running + && !commandJumpOwnsAction + && !giveOwnsAction + && !criticalCraftOwnsAction + && !vitalOwnsAction + && !dispelOwnsAction + && !manaRechargeOwnsAction + && !lootOwnsAction + && !craftingOwnsAction + && !idleCraftingOwnsAction + && !inventoryOwnsAction + && !navigationOwnsAction + && !_combat.HasTarget); + _combat.SetPaused( + _running || commandJumpOwnsAction || giveOwnsAction + || criticalCraftOwnsAction || vitalOwnsAction + || dispelOwnsAction + || manaRechargeOwnsAction + || lootOwnsAction + || craftingOwnsAction + || idleCraftingOwnsAction + || inventoryOwnsAction + || randomHelperOwnsAction + || (navigationOwnsAction && _navigationSettings.Priority)); + _combat.OnTick(elapsedSeconds, _navigationSettings.Enabled); + if (_activeTab == TankTab.Route) + RefreshRouteEditor(); + if (!_running) return; @@ -265,14 +3924,37 @@ internal sealed class MossTankPanel if (automation.Magic.IsCasting) return; - if (TryVitalUpkeep(automation)) + if (vitalOwnsAction) + return; + + if (inventoryOwnsAction) + return; + + if (craftingOwnsAction) + return; + + if (idleCraftingOwnsAction) + return; + + if (manaRechargeOwnsAction) + return; + + if (lootOwnsAction) + return; + + if (giveOwnsAction) return; if (_queueIndex >= _queue.Count) { + bool announce = _announceBuffPass; + bool force = _forcePass; Stop($"Done — {_castThisPass} cast(s)."); - _host.Log.Info($"MossTank: force pass complete ({_castThisPass} cast)"); - Announce($"Finished — {_castThisPass} spell(s) cast."); + _host.Log.Info( + $"MossTank: {(force ? "force" : "automatic")} pass complete " + + $"({_castThisPass} cast)"); + if (announce) + Announce($"Finished — {_castThisPass} spell(s) cast."); return; } @@ -280,10 +3962,238 @@ internal sealed class MossTankPanel // Advance on both outcomes. A spell that will not go now (missing // components, a gate that stays shut) must not block the rest of the // queue behind it; the status line names why it was skipped. - TryCast(automation, next, $"Force buffing {_castThisPass + 1}/{_queue.Count}"); + TryCast( + automation, + next, + $"{(_forcePass ? "Force buffing" : "Buffing")} " + + $"{_castThisPass + 1}/{_queue.Count}"); _queueIndex++; } + private void TickAutomaticBuffing( + double elapsedSeconds, + bool macroRunning, + bool canAct) + { + _automaticBuffScanRemaining -= Math.Max(0d, elapsedSeconds); + if (!macroRunning || !_buffSettings.Enabled || _running || !canAct + || _automaticBuffScanRemaining > 0d) + { + return; + } + + _automaticBuffScanRemaining = 1d; + double threshold = !_combat.HasTarget && _buffSettings.IdleBuffTopoff + ? _buffSettings.IdleBuffTopoffSeconds + : _buffSettings.RebuffWhenUnderSeconds; + StartBuffPass( + _host.Automation, + force: false, + rebuffWhenUnderSeconds: threshold, + announce: false); + } + + private bool TickRandomHelper(double elapsedSeconds, bool canAct) + { + _randomHelperRemaining = Math.Max( + 0d, + _randomHelperRemaining - Math.Max(0d, elapsedSeconds)); + if (!canAct + || !_buffSettings.RandomHelperBuffs + || _randomHelperRemaining > 0d + || !_host.Automation.IsAvailable) + { + return false; + } + if (_host.Automation.Magic.IsCasting) + return true; + + PluginNavigationSnapshot navigation = + _host.Automation.Navigation.Snapshot; + if (!navigation.IsAvailable) + return false; + PluginWorldObject[] players = _host.Automation.Objects.CaptureObjects() + .Where(value => value.ObjectClass == PluginObjectClass.Player + && value.ObjectId != _host.Automation.Character.ObjectId + && value.HasPosition + && navigation.Position.HorizontalDistanceMeters(value.Position) + < 18d) + .OrderBy(static value => value.ObjectId) + .ToArray(); + if (players.Length == 0) + return false; + + string[] stems = + [ + "Endurance Other", "Regeneration Other", "Rejuvenation Other", + "Armor Other", "Blade Protection Other", + "Bludgeoning Protection Other", "Cold Protection Other", + "Fire Protection Other", "Lightning Protection Other", + "Piercing Protection Other", "Acid Protection Other", + ]; + int attempts = players.Length * stems.Length; + for (int offset = 0; offset < attempts; offset++) + { + int slot = (_randomHelperCursor + offset) % attempts; + PluginWorldObject player = players[slot % players.Length]; + string stem = stems[(slot / players.Length) % stems.Length]; + PluginSpellInfo spell = _host.Automation.Spells.KnownSelfBuffs + .Where(value => value.Name.StartsWith( + stem, + StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(static value => value.Quality) + .ThenByDescending(static value => value.Tier) + .FirstOrDefault(); + if (spell.SpellId == 0u + || SpellComponentPolicy.UsesBlacklistedComponent( + _host.Automation.Spells, + spell, + _buffSettings.BlacklistedSpellComponents) + || _host.Automation.Magic.EvaluateGate( + spell.SpellId, + player.ObjectId) != PluginCastGate.Ready + || !_host.Automation.Magic.Cast( + spell.SpellId, + player.ObjectId)) + { + continue; + } + _randomHelperCursor = (slot + 1) % attempts; + _randomHelperRemaining = Math.Max( + 0.25d, + _buffSettings.RandomHelperIntervalSeconds); + _host.Log.Info( + $"MossTank: random helper {spell.Name} -> {player.Name}"); + return true; + } + _randomHelperCursor = (_randomHelperCursor + 1) % attempts; + return false; + } + + private void ShowFirstRunGuidance() + { + if (!_firstRunGuidancePending || !_host.Automation.IsAvailable) + return; + _firstRunGuidancePending = false; + const string guidance = "First run: choose profiles, configure the " + + "Options tab, then press Run Macro. Minimize with –; MossTank " + + "keeps running from the right-side plugin shelf. Put VTank " + + ".nav/.utl/.met files in imports and use /vt nav, /vt loot, or " + + "/vt meta import ."; + Announce(guidance); + try + { + _host.Storage.WriteText("onboarding/v1.txt", "shown"); + } + catch (Exception error) + { + _host.Log.Warn( + "MossTank could not persist first-run guidance state: " + + error.Message); + } + } + + private static bool NeedsFirstRunGuidance(IPluginHost host) + { + if (!host.Storage.IsAvailable) + return false; + try + { + return string.IsNullOrWhiteSpace( + host.Storage.ReadText("onboarding/v1.txt")); + } + catch (Exception error) + { + host.Log.Warn( + "MossTank could not read first-run guidance state: " + + error.Message); + return false; + } + } + + public void Disable() + { + if (_combat.Enabled) + SetMacroRunning(false); + if (_running) + Stop("Stopped."); + _vitalRecharge.Reset(); + _inventoryMaintenance.Reset(); + _crafting.Reset(); + _itemManaRecharge.Reset(); + _loot.Reset(); + _profileGive.Reset(); + _navigation.Reset(); + _fellowshipManager.Reset(); + _meta.SetEnabled(false); + _metaViews.DestroyAll(); + _expressions.DestroyAuxiliaryViews(); + _expressions.ClearSession(); + } + + private void HandleSessionEnded() + { + // Combat owns the authoritative physical abort and all of its receipt + // cursors. Drive its existing session-loss path before resetting the + // sibling schedulers. + if (_combat.Enabled) + _combat.OnTick(0d, navigationEnabled: false); + + ClearFastCastMovement(); + _running = false; + _forcePass = false; + _announceBuffPass = false; + _queue.Clear(); + _queueIndex = 0; + _selectionBeforePass = null; + _status = "Lost the session."; + ResetSessionScopedControllers(); + } + + private void HandleSessionStarted() + { + // Re-baseline portal/death/chat state against the NEW session. This is + // also required for a same-character relog, where identity-based + // persistence cannot itself distinguish the old and new sessions. + _meta.ResetSession(); + _expressions.ClearSession(); + _expressions.DestroyAuxiliaryViews(); + _metaViews.DestroyAll(); + ResetCommandSession(); + _automaticBuffScanRemaining = 0d; + _buffCastRecastRemaining = 0d; + _randomHelperRemaining = 0d; + _randomHelperCursor = 0; + _coverageSpellSnapshot = null; + _coverageRefreshRemaining = 0d; + _status = "Idle."; + } + + private void ResetSessionScopedControllers() + { + _vitalRecharge.Reset(); + _dispel.Reset(); + _inventoryMaintenance.Reset(); + _crafting.Reset(); + _itemManaRecharge.Reset(); + _loot.Reset(); + _profileGive.Reset(); + _navigation.Reset(); + _fellowshipManager.Reset(); + _meta.ResetSession(); + _metaViews.DestroyAll(); + _expressions.DestroyAuxiliaryViews(); + _expressions.ClearSession(); + ResetCommandSession(); + _automaticBuffScanRemaining = 0d; + _buffCastRecastRemaining = 0d; + _randomHelperRemaining = 0d; + _randomHelperCursor = 0; + _coverageSpellSnapshot = null; + _coverageRefreshRemaining = 0d; + _combatSettings.MetaState = MetaEngine.DefaultState; + } + private void RefreshDisplayBindings(double elapsedSeconds) { IAutomationSurface automation = _host.Automation; @@ -352,28 +4262,6 @@ internal sealed class MossTankPanel _coverageRefreshRemaining = CoverageRefreshIntervalSeconds; } - private bool TryVitalUpkeep(IAutomationSurface automation) - { - VitalAction action = VitalPlan.Decide(automation.Character, _vitalSettings); - if (action == VitalAction.None) - return false; - - string stem = action == VitalAction.StaminaToMana - ? VitalPlan.StaminaToManaStem - : VitalPlan.RevitalizeStem; - - if (!VitalPlan.TryFind( - automation.Spells.KnownSelfBuffs, stem, SkillLevels(automation), - _buffSettings.SkillExcessOverDifficulty, out PluginSpellInfo spell)) - { - // Not knowing the conversion is not an error — plenty of characters - // do not have it. Fall through to buffing rather than stalling. - return false; - } - - return TryCast(automation, spell, action.ToString()); - } - private bool TryCast( IAutomationSurface automation, PluginSpellInfo spell, string label) { @@ -406,10 +4294,68 @@ internal sealed class MossTankPanel return false; } + BeginFastCastMovement(automation, spell); _castThisPass++; _sinceProgress = 0; _status = $"{label}: {spell.Name}"; _host.Log.Info($"MossTank: casting {spell.Name} (0x{spell.SpellId:X4})"); return true; } + + private void BeginFastCastMovement( + IAutomationSurface automation, + in PluginSpellInfo spell) + { + if (!_buffSettings.FastCastBuffs || !IsVtankInstantCast(spell)) + return; + // VTank excludes War (school 1) and Void (school 5). The plugin API + // projects schools as their retail skill ids: 34 and 43 respectively. + if (spell.School is 34u or 43u) + return; + + PluginNavigationCommandStatus result = automation.Navigation + .SetMovementIntent(new PluginMovementIntent(Forward: true)); + if (result != PluginNavigationCommandStatus.Accepted) + return; + _fastCastMovementActive = true; + _fastCastStartCompletionRevision = automation.Magic.LastCompletion.Revision; + _fastCastMovementElapsed = 0d; + } + + private void ObserveFastCastMovement(double elapsedSeconds) + { + if (!_fastCastMovementActive) + return; + _fastCastMovementElapsed += Math.Max(0d, elapsedSeconds); + IMagicCommands magic = _host.Automation.Magic; + bool receiptArrived = magic.LastCompletion.Revision + != _fastCastStartCompletionRevision; + bool castEnded = _fastCastMovementElapsed >= 0.2d && !magic.IsCasting; + if (receiptArrived || castEnded || _fastCastMovementElapsed >= 10d) + ClearFastCastMovement(); + } + + private void ClearFastCastMovement() + { + if (!_fastCastMovementActive) + return; + _host.Automation.Navigation.ClearMovementIntent(); + _fastCastMovementActive = false; + _fastCastStartCompletionRevision = 0; + _fastCastMovementElapsed = 0d; + } + + private static bool IsVtankInstantCast(in PluginSpellInfo spell) + { + if (spell.Difficulty < 50) + return true; + if (spell.IsUntargeted + && !spell.IsFellowship + && spell.DurationSeconds >= 60f + && spell.School is 31u or 33u) + { + return true; + } + return spell.Family is >= 243u and <= 249u or 639u; + } } diff --git a/src/AcDream.Plugins.MossTank/MossTankPlugin.cs b/src/AcDream.Plugins.MossTank/MossTankPlugin.cs index 7b158272..dbcd3974 100644 --- a/src/AcDream.Plugins.MossTank/MossTankPlugin.cs +++ b/src/AcDream.Plugins.MossTank/MossTankPlugin.cs @@ -17,6 +17,7 @@ public sealed class MossTankPlugin : IAcDreamPlugin private IPluginHost? _host; private MossTankPanel? _panel; private Action? _tick; + private IDisposable? _commandRegistration; public void Initialize(IPluginHost host) { @@ -36,10 +37,19 @@ public sealed class MossTankPlugin : IAcDreamPlugin string directory = Path.GetDirectoryName(typeof(MossTankPlugin).Assembly.Location) ?? "."; - // Two panels with complementary visible bindings stand in for a tab - // control: only one is ever on screen, and switching is just an Action. - _host.Ui.AddMarkupPanel(Path.Combine(directory, "mosstank.xml"), _panel); - _host.Ui.AddMarkupPanel(Path.Combine(directory, "mosstank-settings.xml"), _panel); + _host.Ui.AddPanel( + new PluginPanelDescriptor("main", "MossTank") + { + IconText = "MT", + StartVisible = true, + ShowInSidePanel = true, + }, + Path.Combine(directory, "mosstank.xml"), + _panel); + + _commandRegistration = _host.Commands.Register( + "vt", + _panel.ExecuteVtankCommand); _tick = _panel.OnTick; _host.Events.Tick += _tick; @@ -55,7 +65,10 @@ public sealed class MossTankPlugin : IAcDreamPlugin { if (_host is not null && _tick is not null) _host.Events.Tick -= _tick; + _commandRegistration?.Dispose(); + _commandRegistration = null; _tick = null; + _panel?.Disable(); _host?.Log.Info("MossTank disabled"); } } diff --git a/src/AcDream.Plugins.MossTank/MossTankProfileRecovery.cs b/src/AcDream.Plugins.MossTank/MossTankProfileRecovery.cs new file mode 100644 index 00000000..12701bf8 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/MossTankProfileRecovery.cs @@ -0,0 +1,46 @@ +using System.Security.Cryptography; +using System.Text; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// Preserves an unreadable profile before a caller falls back to defaults. +/// Recovery is intentionally append-only and manifest-scoped; a corrupt file +/// is never deleted or silently overwritten as part of load. +/// +internal static class MossTankProfileRecovery +{ + internal static string Preserve( + IPluginHost host, + string family, + string key, + string? content, + Exception error) + { + string summary = $"{family} profile '{key}' could not be loaded: " + + error.Message; + if (!host.Storage.IsAvailable || string.IsNullOrEmpty(content)) + return summary; + + try + { + byte[] identity = SHA256.HashData( + Encoding.UTF8.GetBytes(key + "\n" + content)); + string recoveryKey = $"recovery/{family.ToLowerInvariant()}/" + + $"{Convert.ToHexString(identity)[..16]}.txt"; + string payload = $"Original key: {key}\n" + + $"Load error: {error.Message}\n\n" + + content; + host.Storage.WriteText(recoveryKey, payload); + return summary + $" Raw data was preserved as {recoveryKey}."; + } + catch (Exception backupError) + { + host.Log.Warn( + $"MossTank could not preserve corrupt {family} profile: " + + backupError.Message); + return summary; + } + } +} diff --git a/src/AcDream.Plugins.MossTank/MossTankProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankProfileStore.cs new file mode 100644 index 00000000..7f501444 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/MossTankProfileStore.cs @@ -0,0 +1,922 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// VTank-compatible macro profile lifecycle. "By char" resolves to a distinct +/// durable document per character; named profiles are explicit shared copies. +/// +internal sealed class MossTankProfileStore +{ + public const string ByCharacter = "By char"; + private const string LegacyKey = "profile.json"; + private const string IndexKey = "profiles/index.json"; + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + }; + + private readonly IPluginHost _host; + private ProfileIndex _index; + private string _characterName = string.Empty; + private string _selected = ByCharacter; + + public MossTankProfileStore(IPluginHost host) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _index = Read(IndexKey) ?? new ProfileIndex(); + _index.Profiles ??= []; + _index.SelectedByCharacter = new Dictionary( + _index.SelectedByCharacter + ?? new Dictionary(), + StringComparer.OrdinalIgnoreCase); + } + + public string Selected => _selected; + public bool MineOnly => _index.MineOnly; + public string? RecoveryNotice { get; private set; } + + public IReadOnlyList AvailableNames + { + get + { + IEnumerable entries = _index.Profiles; + if (MineOnly && !string.IsNullOrWhiteSpace(_characterName)) + { + entries = entries.Where(entry => string.Equals( + entry.Owner, + _characterName, + StringComparison.OrdinalIgnoreCase)); + } + return new[] { ByCharacter } + .Concat(entries.Select(static entry => entry.Name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(static name => name.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .ThenBy(static name => name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + } + + /// Returns true when a different character/profile must be loaded. + public bool BindCharacter(string? characterName) + { + string normalized = string.IsNullOrWhiteSpace(characterName) + ? string.Empty + : characterName.Trim(); + if (string.Equals(normalized, _characterName, StringComparison.OrdinalIgnoreCase)) + return false; + + _characterName = normalized; + string selectionKey = CharacterSelectionKey(); + _selected = _index.SelectedByCharacter.TryGetValue( + selectionKey, + out string? selected) + && IsKnown(selected) + ? CanonicalName(selected) + : ByCharacter; + if (!_index.SelectedByCharacter.ContainsKey(selectionKey)) + { + _index.SelectedByCharacter[selectionKey] = _selected; + SaveIndex(); + } + return true; + } + + public void SetMineOnly(bool value) + { + if (_index.MineOnly == value) + return; + _index.MineOnly = value; + if (!AvailableNames.Contains(_selected, StringComparer.OrdinalIgnoreCase)) + { + _selected = ByCharacter; + _index.SelectedByCharacter[CharacterSelectionKey()] = _selected; + } + SaveIndex(); + } + + public bool Select(string? name) + { + string normalized = NormalizeName(name); + if (!IsKnown(normalized)) + return false; + _selected = CanonicalName(normalized); + _index.SelectedByCharacter[CharacterSelectionKey()] = _selected; + SaveIndex(); + return true; + } + + public bool Create( + string? name, + bool copyCurrent, + CombatSettings combat, + BuffSettings buffs, + VitalSettings vitals, + InventorySettings inventory, + ISet noBuffItemNames, + out string notice) + { + string normalized = NormalizeName(name); + if (!ValidNamedProfile(normalized, out notice)) + return false; + + ProfileDocument document = copyCurrent + ? ProfileDocument.Capture( + combat, buffs, vitals, inventory, noBuffItemNames) + : ProfileDocument.CreateDefaults(); + Write(ProfileKey(normalized, byCharacter: false), document); + int existing = _index.Profiles.FindIndex(entry => string.Equals( + entry.Name, + normalized, + StringComparison.OrdinalIgnoreCase)); + var entry = new ProfileEntry { Name = normalized, Owner = _characterName }; + if (existing >= 0) + _index.Profiles[existing] = entry; + else + _index.Profiles.Add(entry); + _selected = normalized; + _index.SelectedByCharacter[CharacterSelectionKey()] = normalized; + SaveIndex(); + document.Apply(combat, buffs, vitals, inventory, noBuffItemNames); + notice = copyCurrent + ? $"Copied current settings to {normalized}." + : $"Created profile {normalized}."; + return true; + } + + public void LoadCurrent( + CombatSettings combat, + BuffSettings buffs, + VitalSettings vitals, + InventorySettings inventory, + ISet noBuffItemNames) + { + ProfileDocument? document = Read(CurrentProfileKey()); + if (document is null + && _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + { + document = Read(LegacyKey); + } + (document ?? ProfileDocument.CreateDefaults()).Apply( + combat, + buffs, + vitals, + inventory, + noBuffItemNames); + } + + public void SaveCurrent( + CombatSettings combat, + BuffSettings buffs, + VitalSettings vitals, + InventorySettings inventory, + ISet noBuffItemNames) => Write( + CurrentProfileKey(), + ProfileDocument.Capture( + combat, buffs, vitals, inventory, noBuffItemNames)); + + public void ClearCurrent( + CombatSettings combat, + BuffSettings buffs, + VitalSettings vitals, + InventorySettings inventory, + ISet noBuffItemNames) + { + ProfileDocument defaults = ProfileDocument.CreateDefaults(); + defaults.Apply(combat, buffs, vitals, inventory, noBuffItemNames); + Write(CurrentProfileKey(), defaults); + } + + /// + /// VTank's opt setinall: update every named profile and every + /// character profile known to the durable index, including the active + /// character even when it has never selected a named profile. + /// + public int SetOptionInAll(string name, MonsterValue value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + var keys = new HashSet(StringComparer.Ordinal); + foreach (ProfileEntry entry in _index.Profiles) + keys.Add(ProfileKey(entry.Name, byCharacter: false)); + foreach (string character in _index.SelectedByCharacter.Keys) + { + keys.Add(ProfileKey( + character.Equals("_default", StringComparison.OrdinalIgnoreCase) + ? string.Empty + : character, + byCharacter: true)); + } + keys.Add(ProfileKey(_characterName, byCharacter: true)); + + foreach (string key in keys) + { + ProfileDocument document = Read(key) + ?? ProfileDocument.CreateDefaults(); + document.Combat ??= CombatProfileDocument.Capture(new CombatSettings()); + document.Combat.DynamicSettings ??= + new Dictionary( + StringComparer.OrdinalIgnoreCase); + document.Combat.DynamicSettings[name] = DynamicSettingDocument.From(value); + Write(key, document); + } + return keys.Count; + } + + private static bool ValidNamedProfile(string name, out string notice) + { + if (name.Length is < 1 or > 64) + { + notice = "Enter a profile name (1-64 characters)."; + return false; + } + if (name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + { + notice = "'By char' is the built-in character profile."; + return false; + } + notice = string.Empty; + return true; + } + + private bool IsKnown(string name) => + name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) + || _index.Profiles.Any(entry => entry.Name.Equals( + name, + StringComparison.OrdinalIgnoreCase)); + + private string CanonicalName(string name) => + name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) + ? ByCharacter + : _index.Profiles.First(entry => entry.Name.Equals( + name, + StringComparison.OrdinalIgnoreCase)).Name; + + private string CurrentProfileKey() => _selected.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) + ? ProfileKey(_characterName, byCharacter: true) + : ProfileKey(_selected, byCharacter: false); + + private static string ProfileKey(string value, bool byCharacter) + { + string identity = (byCharacter ? "char:" : "named:") + + value.Trim().ToUpperInvariant(); + string hash = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(identity))); + return $"profiles/macro/{hash}.json"; + } + + private string CharacterSelectionKey() => string.IsNullOrWhiteSpace( + _characterName) ? "_default" : _characterName; + private static string NormalizeName(string? name) => name?.Trim() ?? string.Empty; + + private T? Read(string key) where T : class + { + if (!_host.Storage.IsAvailable) + return null; + string? json = null; + try + { + json = _host.Storage.ReadText(key); + return string.IsNullOrWhiteSpace(json) + ? null + : JsonSerializer.Deserialize(json, Options); + } + catch (Exception error) + { + RecoveryNotice = MossTankProfileRecovery.Preserve( + _host, + "macro", + key, + json, + error); + _host.Log.Warn(RecoveryNotice); + return null; + } + } + + private void Write(string key, T document) + { + if (!_host.Storage.IsAvailable) + return; + try + { + _host.Storage.WriteText(key, JsonSerializer.Serialize(document, Options)); + } + catch (Exception error) + { + _host.Log.Warn($"MossTank profile could not be saved: {error.Message}"); + } + } + + private void SaveIndex() => Write(IndexKey, _index); + + private sealed class ProfileIndex + { + public int Version { get; set; } = 1; + public bool MineOnly { get; set; } = true; + public List Profiles { get; set; } = []; + public Dictionary SelectedByCharacter { get; set; } = + new(StringComparer.OrdinalIgnoreCase); + } + + private sealed class ProfileEntry + { + public string Name { get; set; } = string.Empty; + public string Owner { get; set; } = string.Empty; + } + + private sealed class ProfileDocument + { + public int Version { get; set; } = 6; + // Version-2 compatibility fields remain at the top level. + public string[] ItemNames { get; set; } = []; + public string[] ConsumableNames { get; set; } = []; + public Dictionary ConsumableCategories + { get; set; } = new(StringComparer.Ordinal); + public string[] NoBuffItemNames { get; set; } = []; + public CombatProfileDocument? Combat { get; set; } + public BuffProfileDocument? Buffs { get; set; } + public VitalProfileDocument? Vitals { get; set; } + public InventoryProfileDocument? Inventory { get; set; } + + public static ProfileDocument Capture( + CombatSettings combat, + BuffSettings buffs, + VitalSettings vitals, + InventorySettings inventory, + ISet noBuffItemNames) => new() + { + ItemNames = Sorted(combat.CombatItemNames), + ConsumableNames = Sorted(combat.ConsumableNames), + ConsumableCategories = combat.ConsumableCategories.ToDictionary( + static pair => pair.Key, + static pair => pair.Value, + StringComparer.Ordinal), + NoBuffItemNames = Sorted(noBuffItemNames), + Combat = CombatProfileDocument.Capture(combat), + Buffs = BuffProfileDocument.Capture(buffs), + Vitals = VitalProfileDocument.Capture(vitals), + Inventory = InventoryProfileDocument.Capture(inventory), + }; + + public static ProfileDocument CreateDefaults() => Capture( + new CombatSettings(), + new BuffSettings(), + new VitalSettings(), + new InventorySettings(), + new HashSet(StringComparer.Ordinal)); + + public void Apply( + CombatSettings combat, + BuffSettings buffs, + VitalSettings vitals, + InventorySettings inventory, + ISet noBuffItemNames) + { + (Combat ?? CombatProfileDocument.Capture(new CombatSettings())) + .Apply(combat); + (Buffs ?? BuffProfileDocument.Capture(new BuffSettings())).Apply(buffs); + (Vitals ?? VitalProfileDocument.Capture(new VitalSettings())).Apply(vitals); + (Inventory ?? InventoryProfileDocument.Capture(new InventorySettings())) + .Apply(inventory); + Replace(combat.CombatItemNames, ItemNames); + combat.CombatItemObjectIds.Clear(); + Replace(combat.ConsumableNames, ConsumableNames); + combat.ConsumableCategories.Clear(); + foreach ((string name, ConsumableCategory category) in + ConsumableCategories + ?? new Dictionary()) + { + if (combat.ConsumableNames.Contains(name)) + combat.ConsumableCategories[name] = category; + } + Replace(noBuffItemNames, NoBuffItemNames); + } + } + + private sealed class InventoryProfileDocument + { + public bool ManaChargesWhenOff { get; set; } = true; + public bool AutoStack { get; set; } = true; + public bool AutoCram { get; set; } + public bool AutoCraftItems { get; set; } = true; + public bool SplitPeas { get; set; } = true; + public int CriticalComponentMinimum { get; set; } = 4; + public int NormalComponentMinimum { get; set; } = 20; + public int IdleComponentMinimum { get; set; } = 20; + public int IdleHealthKitCount { get; set; } = 2; + public int IdleStaminaKitCount { get; set; } = 2; + public int IdleManaKitCount { get; set; } = 2; + public int IdleHealthFoodCount { get; set; } = 15; + public int IdleStaminaFoodCount { get; set; } = 15; + public int IdleManaFoodCount { get; set; } = 15; + public bool RefillWornMana { get; set; } = true; + public int RefillWornManaPercent { get; set; } = 33; + public double ScanIntervalSeconds { get; set; } = 0.25d; + public bool EnableLooting { get; set; } + public string LootClassifierId { get; set; } = string.Empty; + public bool LootPriorityBoost { get; set; } + public bool LootAllCorpses { get; set; } + public bool LootFellowCorpses { get; set; } + public bool LootOnlyRareCorpses { get; set; } + public bool ReadUnknownScrolls { get; set; } = true; + public bool CombineSalvage { get; set; } = true; + public int ManaStoneLootCount { get; set; } = 4; + public int ManaTankMinimumMana { get; set; } = 1000; + public float CorpseApproachRange { get; set; } = 40f; + public double CorpseOpenTimeoutSeconds { get; set; } = 1.5d; + public int BlacklistCorpseOpenAttemptCount { get; set; } = 30; + public double BlacklistCorpseOpenTimeoutSeconds { get; set; } = 200d; + public double CorpseCacheTimeoutMinutes { get; set; } = 60d; + public int CorpseLootItemMaxAttempts { get; set; } = 20; + public double LootScanIntervalSeconds { get; set; } = 0.25d; + public LootRuleDocument[] LootRules { get; set; } = []; + + public static InventoryProfileDocument Capture(InventorySettings settings) => + new() + { + ManaChargesWhenOff = settings.ManaChargesWhenOff, + AutoStack = settings.AutoStack, + AutoCram = settings.AutoCram, + AutoCraftItems = settings.AutoCraftItems, + SplitPeas = settings.SplitPeas, + CriticalComponentMinimum = settings.CriticalComponentMinimum, + NormalComponentMinimum = settings.NormalComponentMinimum, + IdleComponentMinimum = settings.IdleComponentMinimum, + IdleHealthKitCount = settings.IdleHealthKitCount, + IdleStaminaKitCount = settings.IdleStaminaKitCount, + IdleManaKitCount = settings.IdleManaKitCount, + IdleHealthFoodCount = settings.IdleHealthFoodCount, + IdleStaminaFoodCount = settings.IdleStaminaFoodCount, + IdleManaFoodCount = settings.IdleManaFoodCount, + RefillWornMana = settings.RefillWornMana, + RefillWornManaPercent = settings.RefillWornManaPercent, + ScanIntervalSeconds = settings.ScanIntervalSeconds, + EnableLooting = settings.Loot.Enabled, + LootClassifierId = settings.Loot.ExternalClassifierId, + LootPriorityBoost = settings.Loot.PriorityBoost, + LootAllCorpses = settings.Loot.LootAllCorpses, + LootFellowCorpses = settings.Loot.LootFellowCorpses, + LootOnlyRareCorpses = settings.Loot.LootOnlyRareCorpses, + ReadUnknownScrolls = settings.Loot.ReadUnknownScrolls, + CombineSalvage = settings.Loot.CombineSalvage, + ManaStoneLootCount = settings.Loot.ManaStoneLootCount, + ManaTankMinimumMana = settings.Loot.ManaTankMinimumMana, + CorpseApproachRange = settings.Loot.CorpseApproachRange, + CorpseOpenTimeoutSeconds = + settings.Loot.CorpseOpenTimeoutSeconds, + BlacklistCorpseOpenAttemptCount = + settings.Loot.BlacklistCorpseOpenAttemptCount, + BlacklistCorpseOpenTimeoutSeconds = + settings.Loot.BlacklistCorpseOpenTimeoutSeconds, + CorpseCacheTimeoutMinutes = + settings.Loot.CorpseCacheTimeoutMinutes, + CorpseLootItemMaxAttempts = + settings.Loot.CorpseLootItemMaxAttempts, + LootScanIntervalSeconds = settings.Loot.ScanIntervalSeconds, + LootRules = settings.Loot.Rules + .Select(LootRuleDocument.From) + .ToArray(), + }; + + public void Apply(InventorySettings settings) + { + settings.ManaChargesWhenOff = ManaChargesWhenOff; + settings.AutoStack = AutoStack; + settings.AutoCram = AutoCram; + settings.AutoCraftItems = AutoCraftItems; + settings.SplitPeas = SplitPeas; + settings.CriticalComponentMinimum = Math.Clamp( + CriticalComponentMinimum, 0, 1000); + settings.NormalComponentMinimum = Math.Clamp( + NormalComponentMinimum, 0, 1000); + settings.IdleComponentMinimum = Math.Clamp( + IdleComponentMinimum, 0, 1000); + settings.IdleHealthKitCount = Math.Clamp(IdleHealthKitCount, 0, 1000); + settings.IdleStaminaKitCount = Math.Clamp(IdleStaminaKitCount, 0, 1000); + settings.IdleManaKitCount = Math.Clamp(IdleManaKitCount, 0, 1000); + settings.IdleHealthFoodCount = Math.Clamp(IdleHealthFoodCount, 0, 1000); + settings.IdleStaminaFoodCount = Math.Clamp(IdleStaminaFoodCount, 0, 1000); + settings.IdleManaFoodCount = Math.Clamp(IdleManaFoodCount, 0, 1000); + settings.RefillWornMana = RefillWornMana; + settings.RefillWornManaPercent = Math.Clamp( + RefillWornManaPercent, + 0, + 99); + settings.ScanIntervalSeconds = Math.Clamp( + ScanIntervalSeconds, + 0.05d, + 10d); + settings.Loot.Enabled = EnableLooting; + settings.Loot.ExternalClassifierId = LootClassifierId?.Trim() + ?? string.Empty; + settings.Loot.PriorityBoost = LootPriorityBoost; + settings.Loot.LootAllCorpses = LootAllCorpses; + settings.Loot.LootFellowCorpses = LootFellowCorpses; + settings.Loot.LootOnlyRareCorpses = LootOnlyRareCorpses; + settings.Loot.ReadUnknownScrolls = ReadUnknownScrolls; + settings.Loot.CombineSalvage = CombineSalvage; + settings.Loot.ManaStoneLootCount = Math.Clamp( + ManaStoneLootCount, + 0, + 100); + settings.Loot.ManaTankMinimumMana = Math.Clamp( + ManaTankMinimumMana, + 1, + int.MaxValue); + settings.Loot.CorpseApproachRange = Math.Clamp( + CorpseApproachRange, + 2f, + 100f); + settings.Loot.CorpseOpenTimeoutSeconds = Math.Clamp( + CorpseOpenTimeoutSeconds, + 0.25d, + 30d); + settings.Loot.BlacklistCorpseOpenAttemptCount = Math.Clamp( + BlacklistCorpseOpenAttemptCount, + 1, + 1000); + settings.Loot.BlacklistCorpseOpenTimeoutSeconds = Math.Clamp( + BlacklistCorpseOpenTimeoutSeconds, + 1d, + 3600d); + settings.Loot.CorpseCacheTimeoutMinutes = Math.Clamp( + CorpseCacheTimeoutMinutes, + 1d, + 1440d); + settings.Loot.CorpseLootItemMaxAttempts = Math.Clamp( + CorpseLootItemMaxAttempts, + 1, + 100); + settings.Loot.ScanIntervalSeconds = Math.Clamp( + LootScanIntervalSeconds, + 0.05d, + 5d); + settings.Loot.Rules.Clear(); + foreach (LootRuleDocument rule in LootRules ?? []) + settings.Loot.Rules.Add(rule.ToRule()); + } + } + + private sealed class LootRuleDocument + { + public string Name { get; set; } = "Rule"; + public string Expression { get; set; } = "*"; + public LootAction Action { get; set; } = LootAction.Keep; + public int KeepCount { get; set; } = 1; + public int Priority { get; set; } + + public static LootRuleDocument From(LootRule rule) => new() + { + Name = rule.Name, + Expression = rule.Expression, + Action = rule.Action, + KeepCount = rule.KeepCount, + Priority = rule.Priority, + }; + + public LootRule ToRule() => new() + { + Name = string.IsNullOrWhiteSpace(Name) ? "Rule" : Name.Trim(), + Expression = string.IsNullOrWhiteSpace(Expression) + ? "*" + : Expression.Trim(), + Action = Action, + KeepCount = Math.Clamp(KeepCount, 0, 100000), + Priority = Math.Clamp(Priority, -1000, 1000), + }; + } + + private sealed class CombatProfileDocument + { + public bool Enabled { get; set; } = true; + public float MaximumRange { get; set; } = 5f; + public float ApproachDistance { get; set; } + public bool IdlePeaceMode { get; set; } + public TargetSelectionMethod SelectionMethod { get; set; } = + TargetSelectionMethod.Both; + public float TargetSelectAngleRange { get; set; } = 5f; + public bool TargetLock { get; set; } + public PluginAttackHeight AttackHeight { get; set; } = + PluginAttackHeight.Medium; + public float AttackPower { get; set; } = 0.5f; + public bool AutoAttackPower { get; set; } = true; + public bool UseRecklessness { get; set; } = true; + public double ScanIntervalSeconds { get; set; } = 0.25; + public DebuffEachFirst DebuffEachFirst { get; set; } = DebuffEachFirst.One; + public DebuffSelectionMethod DebuffSelectionMethod { get; set; } = + DebuffSelectionMethod.Skill; + public double DebuffPrecastSeconds { get; set; } = 5d; + public bool SwitchWandsToDebuff { get; set; } + public bool UseArcs { get; set; } = true; + public float ArcRange { get; set; } = 5f; + public float RingDistance { get; set; } = 5f; + public int MinimumRingTargets { get; set; } = 4; + public bool DeleteGhostMonsters { get; set; } = true; + public int GhostMonsterSpellAttemptCount { get; set; } = 200; + public int BlacklistMonsterAttemptCount { get; set; } = 4; + public double BlacklistMonsterTimeoutSeconds { get; set; } = 120d; + public bool DeleteGhostMonstersByHealthTracker { get; set; } = true; + public double GhostDeleteHealthTrackerSeconds { get; set; } = 30d; + public bool SummonPets { get; set; } = true; + public PetRangeMode PetRangeMode { get; set; } = PetRangeMode.AttackDistance; + public float PetCustomRange { get; set; } = 5f; + public int PetMonsterDensity { get; set; } = 1; + public int PetRefillCountIdle { get; set; } = 3; + public int PetRefillCountNormal { get; set; } = 1; + public string MetaState { get; set; } = "Default"; + public Dictionary DynamicSettings { get; set; } = + new(StringComparer.OrdinalIgnoreCase); + public MonsterRuleDocument[] Rules { get; set; } = + [MonsterRuleDocument.From(new MonsterRule("DEFAULT", 0))]; + + public static CombatProfileDocument Capture(CombatSettings value) => new() + { + Enabled = value.Enabled, + MaximumRange = value.MaximumRange, + ApproachDistance = value.ApproachDistance, + IdlePeaceMode = value.IdlePeaceMode, + SelectionMethod = value.SelectionMethod, + TargetSelectAngleRange = value.TargetSelectAngleRange, + TargetLock = value.TargetLock, + AttackHeight = value.AttackHeight, + AttackPower = value.AttackPower, + AutoAttackPower = value.AutoAttackPower, + UseRecklessness = value.UseRecklessness, + ScanIntervalSeconds = value.ScanIntervalSeconds, + DebuffEachFirst = value.DebuffEachFirst, + DebuffSelectionMethod = value.DebuffSelectionMethod, + DebuffPrecastSeconds = value.DebuffPrecastSeconds, + SwitchWandsToDebuff = value.SwitchWandsToDebuff, + UseArcs = value.UseArcs, + ArcRange = value.ArcRange, + RingDistance = value.RingDistance, + MinimumRingTargets = value.MinimumRingTargets, + DeleteGhostMonsters = value.DeleteGhostMonsters, + GhostMonsterSpellAttemptCount = value.GhostMonsterSpellAttemptCount, + BlacklistMonsterAttemptCount = value.BlacklistMonsterAttemptCount, + BlacklistMonsterTimeoutSeconds = value.BlacklistMonsterTimeoutSeconds, + DeleteGhostMonstersByHealthTracker = value.DeleteGhostMonstersByHealthTracker, + GhostDeleteHealthTrackerSeconds = value.GhostDeleteHealthTrackerSeconds, + SummonPets = value.SummonPets, + PetRangeMode = value.PetRangeMode, + PetCustomRange = value.PetCustomRange, + PetMonsterDensity = value.PetMonsterDensity, + PetRefillCountIdle = value.PetRefillCountIdle, + PetRefillCountNormal = value.PetRefillCountNormal, + MetaState = value.MetaState, + DynamicSettings = value.DynamicSettings.ToDictionary( + static pair => pair.Key, + static pair => DynamicSettingDocument.From(pair.Value), + StringComparer.OrdinalIgnoreCase), + Rules = value.Rules.Select(MonsterRuleDocument.From).ToArray(), + }; + + public void Apply(CombatSettings value) + { + value.Enabled = Enabled; + value.MaximumRange = Math.Clamp(MaximumRange, 2f, 100f); + value.ApproachDistance = Math.Clamp(ApproachDistance, 0f, 100f); + value.IdlePeaceMode = IdlePeaceMode; + value.SelectionMethod = SelectionMethod; + value.TargetSelectAngleRange = Math.Clamp( + TargetSelectAngleRange, 2f, value.MaximumRange); + value.TargetLock = TargetLock; + value.AttackHeight = AttackHeight; + value.AttackPower = Math.Clamp(AttackPower, 0f, 1f); + value.AutoAttackPower = AutoAttackPower; + value.UseRecklessness = UseRecklessness; + value.ScanIntervalSeconds = Math.Clamp(ScanIntervalSeconds, 0.05, 5d); + value.DebuffEachFirst = DebuffEachFirst; + value.DebuffSelectionMethod = DebuffSelectionMethod; + value.DebuffPrecastSeconds = Math.Clamp(DebuffPrecastSeconds, 0d, 60d); + value.SwitchWandsToDebuff = SwitchWandsToDebuff; + value.UseArcs = UseArcs; + value.ArcRange = Math.Clamp(ArcRange, 1f, 100f); + value.RingDistance = Math.Clamp(RingDistance, 1f, 100f); + value.MinimumRingTargets = Math.Clamp(MinimumRingTargets, 1, 25); + value.DeleteGhostMonsters = DeleteGhostMonsters; + value.GhostMonsterSpellAttemptCount = Math.Clamp( + GhostMonsterSpellAttemptCount, 1, 1000); + value.BlacklistMonsterAttemptCount = Math.Clamp( + BlacklistMonsterAttemptCount, 1, 20); + value.BlacklistMonsterTimeoutSeconds = Math.Clamp( + BlacklistMonsterTimeoutSeconds, 1d, 3600d); + value.DeleteGhostMonstersByHealthTracker = DeleteGhostMonstersByHealthTracker; + value.GhostDeleteHealthTrackerSeconds = Math.Clamp( + GhostDeleteHealthTrackerSeconds, 1d, 300d); + value.SummonPets = SummonPets; + value.PetRangeMode = PetRangeMode; + value.PetCustomRange = Math.Clamp(PetCustomRange, 1f, 100f); + value.PetMonsterDensity = Math.Clamp(PetMonsterDensity, 1, 25); + value.PetRefillCountIdle = Math.Clamp(PetRefillCountIdle, 0, 3); + value.PetRefillCountNormal = Math.Clamp(PetRefillCountNormal, 0, 3); + value.MetaState = string.IsNullOrWhiteSpace(MetaState) + ? "Default" + : MetaState; + value.DynamicSettings.Clear(); + foreach ((string name, DynamicSettingDocument setting) in + DynamicSettings ?? new Dictionary()) + { + value.DynamicSettings[name] = setting.ToValue(); + } + value.Rules.Clear(); + foreach (MonsterRuleDocument rule in Rules ?? []) + { + try { value.Rules.Add(rule.ToRule()); } + catch (FormatException) { } + } + if (!value.Rules.Any(static rule => rule.IsDefault)) + value.Rules.Add(new MonsterRule("DEFAULT", 0)); + } + } + + private sealed class DynamicSettingDocument + { + public MonsterValueKind Kind { get; set; } + public double Number { get; set; } + public string Text { get; set; } = string.Empty; + public bool Boolean { get; set; } + + public static DynamicSettingDocument From(MonsterValue value) => new() + { + Kind = value.Kind, + Number = value.Number, + Text = value.Text, + Boolean = value.Boolean, + }; + + public MonsterValue ToValue() => Kind switch + { + MonsterValueKind.Number => MonsterValue.FromNumber(Number), + MonsterValueKind.Boolean => MonsterValue.FromBoolean(Boolean), + _ => MonsterValue.FromText(Text ?? string.Empty), + }; + } + + private sealed class MonsterRuleDocument + { + public string Expression { get; set; } = "DEFAULT"; + public MonsterActionFlags Flags { get; set; } = MonsterActionFlags.Attack; + public int Priority { get; set; } + public MonsterDamageType DamageType { get; set; } = MonsterDamageType.Auto; + public MonsterDamageType ExtraVulnerability { get; set; } = + MonsterDamageType.Auto; + public uint WeaponObjectId { get; set; } + public uint OffhandObjectId { get; set; } + public string WeaponName { get; set; } = string.Empty; + public string OffhandName { get; set; } = string.Empty; + public MonsterDamageType PetDamageType { get; set; } = + MonsterDamageType.PlayerAuto; + + public static MonsterRuleDocument From(MonsterRule rule) => new() + { + Expression = rule.Expression, + Flags = rule.Actions.Flags, + Priority = rule.Actions.Priority, + DamageType = rule.Actions.DamageType, + ExtraVulnerability = rule.Actions.ExtraVulnerability, + WeaponObjectId = rule.Actions.WeaponObjectId, + OffhandObjectId = rule.Actions.OffhandObjectId, + WeaponName = rule.Actions.WeaponName, + OffhandName = rule.Actions.OffhandName, + PetDamageType = rule.Actions.PetDamageType, + }; + + public MonsterRule ToRule() => new(Expression, new MonsterRuleActions + { + Flags = Flags, + Priority = Math.Clamp(Priority, -1, 4), + DamageType = DamageType, + ExtraVulnerability = ExtraVulnerability, + WeaponObjectId = WeaponObjectId, + OffhandObjectId = OffhandObjectId, + WeaponName = WeaponName ?? string.Empty, + OffhandName = OffhandName ?? string.Empty, + PetDamageType = PetDamageType, + }); + } + + private sealed class BuffProfileDocument + { + public bool Enabled { get; set; } = true; + public bool IdleBuffTopoff { get; set; } + public double IdleBuffTopoffSeconds { get; set; } = 1200d; + public double RebuffWhenUnderSeconds { get; set; } = 300d; + public int SkillExcessOverDifficulty { get; set; } = 5; + public bool BuffAttributes { get; set; } = true; + public bool BuffProtections { get; set; } = true; + public bool BuffAuras { get; set; } = true; + public bool BuffBanes { get; set; } = true; + public bool BuffRegeneration { get; set; } = true; + public bool BuffOther { get; set; } + public bool BuffTrainedSkillsOnly { get; set; } = true; + + public static BuffProfileDocument Capture(BuffSettings value) => new() + { + Enabled = value.Enabled, + IdleBuffTopoff = value.IdleBuffTopoff, + IdleBuffTopoffSeconds = value.IdleBuffTopoffSeconds, + RebuffWhenUnderSeconds = value.RebuffWhenUnderSeconds, + SkillExcessOverDifficulty = value.SkillExcessOverDifficulty, + BuffAttributes = value.BuffAttributes, + BuffProtections = value.BuffProtections, + BuffAuras = value.BuffAuras, + BuffBanes = value.BuffBanes, + BuffRegeneration = value.BuffRegeneration, + BuffOther = value.BuffOther, + BuffTrainedSkillsOnly = value.BuffTrainedSkillsOnly, + }; + + public void Apply(BuffSettings value) + { + value.Enabled = Enabled; + value.IdleBuffTopoff = IdleBuffTopoff; + value.IdleBuffTopoffSeconds = Math.Clamp( + IdleBuffTopoffSeconds, 30d, 7200d); + value.RebuffWhenUnderSeconds = Math.Clamp( + RebuffWhenUnderSeconds, 30d, 1800d); + value.SkillExcessOverDifficulty = Math.Clamp( + SkillExcessOverDifficulty, -100, 100); + value.BuffAttributes = BuffAttributes; + value.BuffProtections = BuffProtections; + value.BuffAuras = BuffAuras; + value.BuffBanes = BuffBanes; + value.BuffRegeneration = BuffRegeneration; + value.BuffOther = BuffOther; + value.BuffTrainedSkillsOnly = BuffTrainedSkillsOnly; + } + } + + private sealed class VitalProfileDocument + { + public bool Enabled { get; set; } = true; + public double NormalHealth { get; set; } = 0.75; + public double NormalStamina { get; set; } = 0.50; + public double NormalMana { get; set; } = 0.50; + public double NoTargetHealth { get; set; } = 0.01; + public double NoTargetStamina { get; set; } = 0.01; + public double NoTargetMana { get; set; } = 0.01; + public double HelperHealth { get; set; } = 0.20; + public double HelperStamina { get; set; } = 0.01; + public double HelperMana { get; set; } = 0.01; + public bool HelpOthers { get; set; } = true; + + public static VitalProfileDocument Capture(VitalSettings value) => new() + { + Enabled = value.Enabled, + NormalHealth = value.NormalHealth, + NormalStamina = value.NormalStamina, + NormalMana = value.NormalMana, + NoTargetHealth = value.NoTargetHealth, + NoTargetStamina = value.NoTargetStamina, + NoTargetMana = value.NoTargetMana, + HelperHealth = value.HelperHealth, + HelperStamina = value.HelperStamina, + HelperMana = value.HelperMana, + HelpOthers = value.HelpOthers, + }; + + public void Apply(VitalSettings value) + { + value.Enabled = Enabled; + value.NormalHealth = Clamp(NormalHealth); + value.NormalStamina = Clamp(NormalStamina); + value.NormalMana = Clamp(NormalMana); + value.NoTargetHealth = Clamp(NoTargetHealth); + value.NoTargetStamina = Clamp(NoTargetStamina); + value.NoTargetMana = Clamp(NoTargetMana); + value.HelperHealth = Clamp(HelperHealth); + value.HelperStamina = Clamp(HelperStamina); + value.HelperMana = Clamp(HelperMana); + value.HelpOthers = HelpOthers; + } + + private static double Clamp(double value) => Math.Clamp(value, 0d, 1d); + } + + private static string[] Sorted(IEnumerable values) => values + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + .OrderBy(static value => value, StringComparer.Ordinal) + .ToArray(); + + private static void Replace(ISet target, IEnumerable? values) + { + target.Clear(); + if (values is null) + return; + foreach (string value in values) + { + if (!string.IsNullOrWhiteSpace(value)) + target.Add(value); + } + } +} diff --git a/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs b/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs new file mode 100644 index 00000000..e1773615 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs @@ -0,0 +1,437 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// Independent VTank navigation-profile lifecycle. The selected route is +/// remembered per character; "By char" is a private route document and named +/// profiles are reusable copies. +/// +internal sealed class MossTankRouteProfileStore +{ + public const string ByCharacter = "By char"; + private const string IndexKey = "profiles/route/index.json"; + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + }; + + private readonly IPluginHost _host; + private IndexDocument _index; + private string _characterName = string.Empty; + private string _selected = ByCharacter; + + public MossTankRouteProfileStore(IPluginHost host) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _index = Read(IndexKey) ?? new IndexDocument(); + _index.Names ??= []; + _index.SelectedByCharacter = new Dictionary( + _index.SelectedByCharacter ?? new Dictionary(), + StringComparer.OrdinalIgnoreCase); + } + + public string Selected => _selected; + public string? RecoveryNotice { get; private set; } + public IReadOnlyList AvailableNames => new[] { ByCharacter } + .Concat(_index.Names) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(name => name.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) ? 0 : 1) + .ThenBy(static name => name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + public bool BindCharacter(string? characterName) + { + string normalized = string.IsNullOrWhiteSpace(characterName) + ? string.Empty + : characterName.Trim(); + if (string.Equals(normalized, _characterName, StringComparison.OrdinalIgnoreCase)) + return false; + _characterName = normalized; + _selected = _index.SelectedByCharacter.TryGetValue( + SelectionKey(), + out string? selected) + && IsKnown(selected) + ? CanonicalName(selected) + : ByCharacter; + return true; + } + + public bool Select(string? name) + { + string normalized = name?.Trim() ?? string.Empty; + if (!IsKnown(normalized)) + return false; + _selected = CanonicalName(normalized); + _index.SelectedByCharacter[SelectionKey()] = _selected; + SaveIndex(); + return true; + } + + public bool Create( + string? name, + bool copyCurrent, + NavigationSettings current, + out string notice) + { + string normalized = name?.Trim() ?? string.Empty; + if (normalized.Length is < 1 or > 64) + { + notice = "Enter a route profile name (1-64 characters)."; + return false; + } + if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)) + { + notice = "'By char' is the built-in route profile."; + return false; + } + Write( + ProfileKey(normalized, byCharacter: false), + copyCurrent + ? RouteDocument.Capture(current) + : new RouteDocument()); + if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase)) + _index.Names.Add(normalized); + _selected = _index.Names.First(entry => entry.Equals( + normalized, + StringComparison.OrdinalIgnoreCase)); + _index.SelectedByCharacter[SelectionKey()] = _selected; + SaveIndex(); + WriteLegacyExport(_selected, copyCurrent ? current : new NavigationSettings()); + notice = copyCurrent + ? $"Copied route to {_selected}." + : $"Created route profile {_selected}."; + return true; + } + + public bool LoadCurrent(NavigationSettings target) + { + ArgumentNullException.ThrowIfNull(target); + RouteDocument? document = Read(CurrentKey()); + if (document is null) + return false; + document.Apply(target); + return true; + } + + public void SaveCurrent(NavigationSettings settings) + { + Write(CurrentKey(), RouteDocument.Capture(settings)); + WriteLegacyExport( + _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) + ? string.IsNullOrWhiteSpace(_characterName) + ? ByCharacter + : _characterName + : _selected, + settings); + } + + public bool TryImportLegacy( + string? name, + NavigationSettings target, + ISpellCatalog spells, + out string notice) + { + string normalized = name?.Trim() ?? string.Empty; + if (!_host.Storage.IsAvailable || normalized.Length == 0) + { + notice = "Legacy navigation storage is unavailable."; + return false; + } + string? key = _host.Storage.List("imports") + .Concat(_host.Storage.List("exports")) + .FirstOrDefault(candidate => + candidate.EndsWith(".nav", StringComparison.OrdinalIgnoreCase) + && Path.GetFileNameWithoutExtension(candidate).Equals( + normalized, + StringComparison.OrdinalIgnoreCase)); + string? source = key is null ? null : _host.Storage.ReadText(key); + if (string.IsNullOrWhiteSpace(source)) + { + notice = $"VTank navigation file '{normalized}.nav' was not found in imports."; + return false; + } + if (!VtankNavRouteSerializer.TryLoad(source, target, spells, out string error)) + { + notice = $"Could not import {Path.GetFileName(key)}: {error}"; + return false; + } + if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase)) + _index.Names.Add(normalized); + _selected = _index.Names.First(entry => entry.Equals( + normalized, + StringComparison.OrdinalIgnoreCase)); + _index.SelectedByCharacter[SelectionKey()] = _selected; + SaveIndex(); + SaveCurrent(target); + notice = $"Imported VTank navigation profile {_selected}."; + return true; + } + + public void ClearCurrent(NavigationSettings target) + { + target.Enabled = false; + target.Priority = false; + target.Mode = RouteMode.Circular; + target.MinimumDistanceMeters = 2d; + target.FollowTargetObjectId = 0u; + target.FollowTargetName = string.Empty; + target.FollowAroundCorners = true; + target.OpenDoors = false; + target.Waypoints.Clear(); + SaveCurrent(target); + } + + private bool IsKnown(string? name) => name is not null + && (name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase) + || _index.Names.Contains(name, StringComparer.OrdinalIgnoreCase)); + + private string CanonicalName(string name) => name.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) + ? ByCharacter + : _index.Names.First(entry => entry.Equals( + name, + StringComparison.OrdinalIgnoreCase)); + + private string CurrentKey() => _selected.Equals( + ByCharacter, + StringComparison.OrdinalIgnoreCase) + ? ProfileKey(_characterName, byCharacter: true) + : ProfileKey(_selected, byCharacter: false); + + private static string ProfileKey(string value, bool byCharacter) + { + string identity = (byCharacter ? "char:" : "named:") + + value.Trim().ToUpperInvariant(); + string hash = Convert.ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(identity))); + return $"profiles/route/{hash}.json"; + } + + private string SelectionKey() => string.IsNullOrWhiteSpace(_characterName) + ? "_default" + : _characterName; + + private T? Read(string key) where T : class + { + if (!_host.Storage.IsAvailable) + return null; + string? json = null; + try + { + json = _host.Storage.ReadText(key); + return string.IsNullOrWhiteSpace(json) + ? null + : JsonSerializer.Deserialize(json, Options); + } + catch (Exception error) + { + RecoveryNotice = MossTankProfileRecovery.Preserve( + _host, + "route", + key, + json, + error); + _host.Log.Warn(RecoveryNotice); + return null; + } + } + + private void Write(string key, T document) + { + if (!_host.Storage.IsAvailable) + return; + try + { + _host.Storage.WriteText(key, JsonSerializer.Serialize(document, Options)); + } + catch (Exception error) + { + _host.Log.Warn($"MossTank route profile could not be saved: {error.Message}"); + } + } + + private void SaveIndex() => Write(IndexKey, _index); + + private void WriteLegacyExport(string name, NavigationSettings settings) + { + if (!_host.Storage.IsAvailable) + return; + try + { + _host.Storage.WriteText( + $"exports/{LegacyFileName(name)}.nav", + VtankNavRouteSerializer.Save(settings)); + } + catch (Exception error) + { + _host.Log.Warn( + $"MossTank VTank navigation export could not be saved: {error.Message}"); + } + } + + private static string LegacyFileName(string name) + { + char[] invalid = Path.GetInvalidFileNameChars(); + var result = new StringBuilder(name.Length); + foreach (char value in name.Trim()) + { + result.Append(value is '/' or '\\' || invalid.Contains(value) + ? '_' + : value); + } + return result.Length == 0 ? "Route" : result.ToString(); + } + + private sealed class IndexDocument + { + public int Version { get; set; } = 1; + public List Names { get; set; } = []; + public Dictionary SelectedByCharacter { get; set; } = + new(StringComparer.OrdinalIgnoreCase); + } + + private sealed class RouteDocument + { + public int Version { get; set; } = 1; + public bool Enabled { get; set; } + public bool Priority { get; set; } + public RouteMode Mode { get; set; } = RouteMode.Circular; + public double MinimumDistanceMeters { get; set; } = 2d; + public uint FollowTargetObjectId { get; set; } + public string FollowTargetName { get; set; } = string.Empty; + public bool FollowAroundCorners { get; set; } = true; + public bool OpenDoors { get; set; } + public double DoorIdentifyRangeMeters { get; set; } = 20d; + public double DoorOpenRangeMeters { get; set; } = 4d; + public int DoorLockpickExcessThreshold { get; set; } = -50; + public WaypointDocument[] Waypoints { get; set; } = []; + + public static RouteDocument Capture(NavigationSettings value) => new() + { + Enabled = value.Enabled, + Priority = value.Priority, + Mode = value.Mode, + MinimumDistanceMeters = value.MinimumDistanceMeters, + FollowTargetObjectId = value.FollowTargetObjectId, + FollowTargetName = value.FollowTargetName, + FollowAroundCorners = value.FollowAroundCorners, + OpenDoors = value.OpenDoors, + DoorIdentifyRangeMeters = value.DoorIdentifyRangeMeters, + DoorOpenRangeMeters = value.DoorOpenRangeMeters, + DoorLockpickExcessThreshold = value.DoorLockpickExcessThreshold, + Waypoints = value.Waypoints.Select(WaypointDocument.From).ToArray(), + }; + + public void Apply(NavigationSettings value) + { + value.Enabled = Enabled; + value.Priority = Priority; + value.Mode = Enum.IsDefined(Mode) ? Mode : RouteMode.Circular; + value.MinimumDistanceMeters = Math.Clamp( + MinimumDistanceMeters, + 0.5d, + 50d); + value.FollowTargetObjectId = FollowTargetObjectId; + value.FollowTargetName = FollowTargetName ?? string.Empty; + value.FollowAroundCorners = FollowAroundCorners; + value.OpenDoors = OpenDoors; + value.DoorIdentifyRangeMeters = Math.Clamp( + DoorIdentifyRangeMeters, 1d, 100d); + value.DoorOpenRangeMeters = Math.Clamp( + DoorOpenRangeMeters, 0.5d, value.DoorIdentifyRangeMeters); + value.DoorLockpickExcessThreshold = Math.Clamp( + DoorLockpickExcessThreshold, -500, 500); + value.Waypoints.Clear(); + foreach (WaypointDocument waypoint in Waypoints ?? []) + value.Waypoints.Add(waypoint.ToWaypoint()); + } + } + + private sealed class WaypointDocument + { + public RouteWaypointType Type { get; set; } + public uint CellId { get; set; } + public double EastWest { get; set; } + public double NorthSouth { get; set; } + public double Elevation { get; set; } + public float HeadingDegrees { get; set; } + public bool IsOutdoor { get; set; } + public uint ObjectId { get; set; } + public string ObjectName { get; set; } = string.Empty; + public int LegacyObjectClass { get; set; } + public bool LegacyReferenceValid { get; set; } = true; + public string Text { get; set; } = string.Empty; + public int DurationMilliseconds { get; set; } = 5000; + public RouteRecallKind Recall { get; set; } + public uint RecallSpellId { get; set; } + public string RecallSpellName { get; set; } = string.Empty; + public float JumpHeadingDegrees { get; set; } + public bool JumpRun { get; set; } + public int JumpChargeMilliseconds { get; set; } = 1000; + public RouteJumpDirection JumpDirection { get; set; } + + public static WaypointDocument From(RouteWaypoint value) => new() + { + Type = value.Type, + CellId = value.Position.CellId, + EastWest = value.Position.EastWest, + NorthSouth = value.Position.NorthSouth, + Elevation = value.Position.Elevation, + HeadingDegrees = value.Position.HeadingDegrees, + IsOutdoor = value.Position.IsOutdoor, + ObjectId = value.ObjectId, + ObjectName = value.ObjectName, + LegacyObjectClass = value.LegacyObjectClass, + LegacyReferenceValid = value.LegacyReferenceValid, + Text = value.Text, + DurationMilliseconds = value.DurationMilliseconds, + Recall = value.Recall, + RecallSpellId = value.RecallSpellId, + RecallSpellName = value.RecallSpellName, + JumpHeadingDegrees = value.JumpHeadingDegrees, + JumpRun = value.JumpRun, + JumpChargeMilliseconds = value.JumpChargeMilliseconds, + JumpDirection = value.JumpDirection, + }; + + public RouteWaypoint ToWaypoint() => new() + { + Type = Enum.IsDefined(Type) ? Type : RouteWaypointType.Point, + Position = new PluginNavigationPosition( + CellId, + EastWest, + NorthSouth, + Elevation, + HeadingDegrees, + IsOutdoor), + ObjectId = ObjectId, + ObjectName = ObjectName ?? string.Empty, + LegacyObjectClass = LegacyObjectClass, + LegacyReferenceValid = LegacyReferenceValid, + Text = Text ?? string.Empty, + DurationMilliseconds = Math.Clamp(DurationMilliseconds, 0, 3_600_000), + Recall = Enum.IsDefined(Recall) ? Recall : RouteRecallKind.Lifestone, + RecallSpellId = RecallSpellId, + RecallSpellName = RecallSpellName ?? string.Empty, + JumpHeadingDegrees = float.IsFinite(JumpHeadingDegrees) + ? JumpHeadingDegrees + : 0f, + JumpRun = JumpRun, + JumpChargeMilliseconds = Math.Clamp( + JumpChargeMilliseconds, + 0, + 10_000), + JumpDirection = Enum.IsDefined(JumpDirection) + ? JumpDirection + : RouteJumpDirection.Forward, + }; + } +} diff --git a/src/AcDream.Plugins.MossTank/Navigation.cs b/src/AcDream.Plugins.MossTank/Navigation.cs new file mode 100644 index 00000000..839972ad --- /dev/null +++ b/src/AcDream.Plugins.MossTank/Navigation.cs @@ -0,0 +1,1110 @@ +using System.Globalization; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal enum RouteMode +{ + Circular, + Linear, + Target, + Once, +} + +internal enum RouteWaypointType +{ + Point = 0, + Portal = 1, + Recall = 2, + Pause = 3, + ChatCommand = 4, + OpenVendor = 5, + PortalByName = 6, + UseNpc = 7, + Checkpoint = 8, + Jump = 9, +} + +internal enum RouteRecallKind +{ + Lifestone, + Marketplace, + PrimaryPortal, + SecondaryPortal, +} + +internal enum RouteJumpDirection +{ + Forward, + StrafeLeft, + StrafeRight, +} + +internal sealed class RouteWaypoint +{ + public RouteWaypointType Type { get; set; } + public PluginNavigationPosition Position { get; set; } + public uint ObjectId { get; set; } + public string ObjectName { get; set; } = string.Empty; + /// Decal ObjectClass retained for exact VTank NAV interchange. + public int LegacyObjectClass { get; set; } + /// VTank's serialized coordinate-valid bit for Portal2/UseNPC. + public bool LegacyReferenceValid { get; set; } = true; + public string Text { get; set; } = string.Empty; + public int DurationMilliseconds { get; set; } = 5000; + public RouteRecallKind Recall { get; set; } + public uint RecallSpellId { get; set; } + public string RecallSpellName { get; set; } = string.Empty; + public float JumpHeadingDegrees { get; set; } + public bool JumpRun { get; set; } + public int JumpChargeMilliseconds { get; set; } = 1000; + public RouteJumpDirection JumpDirection { get; set; } + + public RouteWaypoint Clone() => new() + { + Type = Type, + Position = Position, + ObjectId = ObjectId, + ObjectName = ObjectName, + LegacyObjectClass = LegacyObjectClass, + LegacyReferenceValid = LegacyReferenceValid, + Text = Text, + DurationMilliseconds = DurationMilliseconds, + Recall = Recall, + RecallSpellId = RecallSpellId, + RecallSpellName = RecallSpellName, + JumpHeadingDegrees = JumpHeadingDegrees, + JumpRun = JumpRun, + JumpChargeMilliseconds = JumpChargeMilliseconds, + JumpDirection = JumpDirection, + }; + + public string DisplayText => Type switch + { + RouteWaypointType.Point => $"Point: {FormatPosition(Position)}", + RouteWaypointType.Portal => $"Portal: {ObjectLabel}", + RouteWaypointType.Recall => $"Recall: {RecallLabel}", + RouteWaypointType.Pause => string.Create( + CultureInfo.InvariantCulture, + $"Pause: {DurationMilliseconds / 1000d:0.###} seconds"), + RouteWaypointType.ChatCommand => $"Chat command: {Text}", + RouteWaypointType.OpenVendor => ObjectId == 0u + ? "Close Vendor" + : $"Open Vendor: {ObjectLabel}", + RouteWaypointType.PortalByName => $"Portal: {ObjectLabel}", + RouteWaypointType.UseNpc => $"Use NPC: {ObjectLabel}", + RouteWaypointType.Checkpoint => $"Checkpoint: {FormatPosition(Position)}", + RouteWaypointType.Jump => + $"Jump: {JumpHeadingDegrees.ToString("0.0", CultureInfo.InvariantCulture)}d, " + + $"{JumpChargeMilliseconds.ToString(CultureInfo.InvariantCulture)}ms" + + (JumpRun ? ", Shift" : string.Empty) + + $", {JumpDirectionDisplayName(JumpDirection)}", + _ => Type.ToString(), + }; + + private string ObjectLabel => string.IsNullOrWhiteSpace(ObjectName) + ? $"0x{ObjectId:X8}" + : ObjectName; + + internal string RecallLabel => !string.IsNullOrWhiteSpace(RecallSpellName) + ? RecallSpellName + : RecallSpellId != 0u + ? RecallSpellId.ToString(CultureInfo.InvariantCulture) + : RecallDisplayName(Recall); + + internal static string FormatPosition(in PluginNavigationPosition value) + { + string northSouth = value.NorthSouth >= 0d ? "N" : "S"; + string eastWest = value.EastWest >= 0d ? "E" : "W"; + return "(" + + Math.Abs(value.NorthSouth).ToString("0.###", CultureInfo.InvariantCulture) + + northSouth + + ", " + + Math.Abs(value.EastWest).ToString("0.###", CultureInfo.InvariantCulture) + + eastWest + + ")"; + } + + internal static string RecallDisplayName(RouteRecallKind value) => value switch + { + RouteRecallKind.Lifestone => "Lifestone Recall", + RouteRecallKind.Marketplace => "Marketplace Recall", + RouteRecallKind.PrimaryPortal => "Primary Portal Recall", + RouteRecallKind.SecondaryPortal => "Secondary Portal Recall", + _ => value.ToString(), + }; + + private static string JumpDirectionDisplayName(RouteJumpDirection value) => + value switch + { + RouteJumpDirection.StrafeLeft => "Strafe Left", + RouteJumpDirection.StrafeRight => "Strafe Right", + _ => "Forward", + }; +} + +internal sealed class NavigationSettings +{ + public bool Enabled { get; set; } + public bool Priority { get; set; } + public RouteMode Mode { get; set; } = RouteMode.Circular; + public double MinimumDistanceMeters { get; set; } = 2d; + /// + /// VTank's far stop range is the outer validity bound for a navigation + /// rule, not a second arrival threshold. + /// + public double MaximumDistanceMeters { get; set; } = 999999d * 240d; + public double PortalUseDistanceMeters { get; set; } = 4d; + public uint FollowTargetObjectId { get; set; } + public string FollowTargetName { get; set; } = string.Empty; + public bool FollowAroundCorners { get; set; } = true; + public bool OpenDoors { get; set; } + public double DoorIdentifyRangeMeters { get; set; } = 20d; + public double DoorOpenRangeMeters { get; set; } = 4d; + public int DoorLockpickExcessThreshold { get; set; } = -50; + public List Waypoints { get; } = []; +} + +/// +/// VTank's navigation state machine over acdream's canonical movement input. +/// Its steering constants are the official fd.cs behavior: turn outside four +/// degrees, continue forward while turning only inside 45 degrees when farther +/// than three metres (15 degrees when nearer), and stop at NavCloseStopRange. +/// +internal sealed class NavigationController +{ + private const float HeadingToleranceDegrees = 4f; + private const float FarMovingTurnLimitDegrees = 45f; + private const float NearMovingTurnLimitDegrees = 15f; + private const double NearTargetMeters = 3d; + private const double ChatInitialDelaySeconds = 0.2d; + private const double UseRetrySeconds = 2d; + private const double PortalTimeoutSeconds = 30d; + private const double ObjectReacquireRadiusMeters = 2.5d; + private const double PortalExitDistanceMeters = 15d; + private const double RecallExitDistanceMeters = 2.4d; + private const double JumpLaunchGraceSeconds = 0.25d; + private const double JumpCompletionTimeoutSeconds = 3d; + private const double CheckpointRetrySeconds = 15d; + private const double FollowBreadcrumbSpacingMeters = 0.096d; + private const double FollowPathCaptureRangeMeters = 240d; + private const double FollowPathArrivalMeters = 2.4d; + private const double DoorActionTimeoutSeconds = 5d; + private const uint LockpickPublicFlag = 0x00020000u; + private const uint LockpickSkillId = 23u; + private const uint LockpickModifierProperty = 40u; + + private readonly IPluginHost _host; + private readonly NavigationSettings _settings; + private int _index; + private bool _reverse; + private bool _onceComplete; + private RouteWaypoint? _activeAction; + private double _actionElapsed; + private double _retryElapsed; + private long _useCompletionBaseline; + private ulong _chatBaseline; + private bool _actionSent; + private bool _sawPortalSpace; + private bool _jumpReleased; + private bool _jumpAligned; + private bool _jumpSawAirborne; + private double _jumpChargeElapsed; + private double _jumpReleaseElapsed; + private double _checkpointElapsed; + private readonly List _followPath = []; + private uint _activeDoorObjectId; + private uint _activeLockpickObjectId; + private double _doorElapsed; + private double _doorRetryElapsed; + private PluginNavigationPosition _portalOrigin; + private bool _hasPortalOrigin; + private bool _hadMovementIntent; + private string _status = "Navigation disabled."; + + public NavigationController(IPluginHost host, NavigationSettings settings) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + } + + public string Status => _status; + public int CurrentWaypointIndex => _index; + public bool Reversing => _reverse; + + public void ToggleReverse() + { + _reverse = !_reverse; + _status = $"Nav backwards is {_reverse}."; + } + + public void Reset() + { + StopMovement(); + _index = 0; + _reverse = false; + _onceComplete = false; + _checkpointElapsed = 0d; + _followPath.Clear(); + ClearDoor(); + ClearAction(); + _status = _settings.Enabled + ? "Route ready." + : "Navigation disabled."; + } + + public void ClearActionLocks() + { + StopMovement(); + _checkpointElapsed = 0d; + ClearDoor(); + ClearAction(); + _status = _settings.Enabled + ? "Route action locks cleared." + : "Navigation disabled."; + } + + public bool Tick(double elapsedSeconds, bool canAct) + { + elapsedSeconds = double.IsFinite(elapsedSeconds) + ? Math.Max(0d, elapsedSeconds) + : 0d; + INavigationAutomation navigation = _host.Automation.Navigation; + PluginNavigationSnapshot snapshot = navigation.Snapshot; + if (!_settings.Enabled || !snapshot.IsAvailable) + { + StopMovement(); + _status = _settings.Enabled + ? "Waiting for the world." + : "Navigation disabled."; + return false; + } + if (snapshot.IsPortalSpace) + { + StopMovement(); + if (_activeAction?.Type is RouteWaypointType.Portal + or RouteWaypointType.PortalByName + or RouteWaypointType.Recall) + { + _sawPortalSpace = true; + } + _status = "Waiting for portal space."; + return _activeAction is not null; + } + if (!canAct) + { + StopMovement(); + _status = "Navigation paused."; + return false; + } + + if (TickDoor(navigation, snapshot, elapsedSeconds)) + return true; + + if (_settings.Mode == RouteMode.Target) + return TickFollow(navigation, snapshot); + if (_onceComplete || _settings.Waypoints.Count == 0) + { + StopMovement(); + _status = _onceComplete ? "Once route complete." : "Route is empty."; + return false; + } + + _index = Math.Clamp(_index, 0, _settings.Waypoints.Count - 1); + RouteWaypoint waypoint = _settings.Waypoints[_index]; + if (waypoint.Type == RouteWaypointType.Point) + { + double distance = snapshot.Position.HorizontalDistanceMeters( + waypoint.Position); + if (distance > BoundedMaximumDistance()) + { + StopMovement(); + _status = $"Waypoint is outside NavFarStopRange ({distance:0.0}m)."; + return false; + } + if (distance <= BoundedMinimumDistance()) + { + StopMovement(); + AdvanceWaypoint(); + return true; + } + _status = string.Create( + CultureInfo.InvariantCulture, + $"Waypoint {_index + 1}/{_settings.Waypoints.Count}: {distance:0.0}m"); + return Steer(navigation, snapshot.Position, waypoint.Position, distance); + } + if (waypoint.Type == RouteWaypointType.Checkpoint) + return TickCheckpoint(navigation, snapshot, waypoint, elapsedSeconds); + + StopMovement(); + return TickAction(waypoint, elapsedSeconds, snapshot); + } + + private bool TickFollow( + INavigationAutomation navigation, + in PluginNavigationSnapshot snapshot) + { + if (_settings.FollowTargetObjectId == 0u + || !navigation.TryGetObject( + _settings.FollowTargetObjectId, + out PluginNavigationObject target)) + { + StopMovement(); + _status = "Follow target unavailable."; + return false; + } + double distance = snapshot.Position.HorizontalDistanceMeters( + target.Position); + if (distance > BoundedMaximumDistance()) + { + StopMovement(); + _status = $"Follow target is outside NavFarStopRange ({distance:0.0}m)."; + return false; + } + if (distance <= BoundedMinimumDistance()) + { + StopMovement(); + _status = $"Following {target.Name}: holding {distance:0.0}m."; + return false; + } + PluginNavigationPosition destination = CaptureFollowDestination( + snapshot.Position, + target.Position); + double destinationDistance = snapshot.Position.HorizontalDistanceMeters( + destination); + _status = $"Following {target.Name}: {distance:0.0}m."; + return Steer(navigation, snapshot.Position, destination, destinationDistance); + } + + private PluginNavigationPosition CaptureFollowDestination( + in PluginNavigationPosition current, + in PluginNavigationPosition target) + { + if (!_settings.FollowAroundCorners) + { + _followPath.Clear(); + return target; + } + + if (_followPath.Count == 0 + || _followPath[^1].HorizontalDistanceMeters(target) + >= FollowBreadcrumbSpacingMeters) + { + _followPath.Add(target); + } + + for (int index = _followPath.Count - 1; index >= 1; index--) + { + if (DistanceToSegmentMeters( + current, + _followPath[index - 1], + _followPath[index]) < FollowPathArrivalMeters + && current.HorizontalDistanceMeters(_followPath[index]) + < FollowPathCaptureRangeMeters) + { + _followPath.RemoveRange(0, index); + break; + } + } + return _followPath.Count == 0 ? target : _followPath[0]; + } + + private bool TickDoor( + INavigationAutomation navigation, + in PluginNavigationSnapshot snapshot, + double elapsedSeconds) + { + if (!_settings.OpenDoors) + { + ClearDoor(); + return false; + } + + IReadOnlyList objects = navigation.CaptureObjects(); + PluginNavigationObject door = default; + bool found = false; + double nearest = _settings.DoorIdentifyRangeMeters; + foreach (PluginNavigationObject candidate in objects) + { + if (!candidate.IsDoor || candidate.IsOpen) + continue; + double distance = snapshot.Position.HorizontalDistanceMeters( + candidate.Position); + if (_activeDoorObjectId != 0u + && candidate.ObjectId == _activeDoorObjectId) + { + door = candidate; + nearest = distance; + found = true; + break; + } + if (_activeDoorObjectId == 0u && distance <= nearest) + { + door = candidate; + nearest = distance; + found = true; + } + } + + if (!found) + { + ClearDoor(); + return false; + } + if (!door.HasLockState) + { + if (nearest <= _settings.DoorIdentifyRangeMeters + && _host.Automation.Loot.Appraisal.AwaitingObjectId == 0u) + { + _ = _host.Automation.Loot.Identify(door.ObjectId); + } + if (nearest <= _settings.DoorOpenRangeMeters) + { + StopMovement(); + _status = $"Identifying door: {door.Name}."; + return true; + } + return false; + } + if (nearest > _settings.DoorOpenRangeMeters) + { + ClearDoor(); + return false; + } + + if (_activeDoorObjectId == 0u) + { + _activeDoorObjectId = door.ObjectId; + _activeLockpickObjectId = door.IsLocked + ? SelectLockpick(door.LockDifficulty) + : 0u; + if (door.IsLocked && _activeLockpickObjectId == 0u) + { + _status = $"Locked door skipped: {door.Name}."; + ClearDoor(); + return false; + } + } + + StopMovement(); + _doorElapsed += elapsedSeconds; + _doorRetryElapsed += elapsedSeconds; + if (_doorElapsed >= DoorActionTimeoutSeconds) + { + _status = $"Door timed out: {door.Name}."; + ClearDoor(); + return false; + } + if (_doorRetryElapsed == elapsedSeconds || _doorRetryElapsed >= UseRetrySeconds) + { + PluginItemCommandResult result = _activeLockpickObjectId == 0u + ? _host.Automation.Items.Use(door.ObjectId) + : _host.Automation.Items.Apply( + _activeLockpickObjectId, + door.ObjectId); + _doorRetryElapsed = 0d; + _status = result.Accepted + ? _activeLockpickObjectId == 0u + ? $"Opening door: {door.Name}." + : $"Picking lock: {door.Name}." + : $"Waiting for door: {door.Name}."; + } + return true; + } + + private uint SelectLockpick(int difficulty) + { + if (!_host.Automation.Character.TryGetSkill( + LockpickSkillId, + out PluginSkillInfo skill) + || skill.Current < Math.Max( + 0, + difficulty + _settings.DoorLockpickExcessThreshold)) + { + return 0u; + } + + uint selected = 0u; + double bestModifier = double.MinValue; + foreach (PluginInventoryItem item in _host.Automation.Items.CaptureOwnedItems()) + { + if ((item.PublicFlags & LockpickPublicFlag) == 0u) + continue; + double modifier = 0d; + if (_host.Automation.Items.TryCaptureProperties( + item.ObjectId, + out PluginItemProperties properties) + && properties.Floats.TryGetValue( + LockpickModifierProperty, + out double current)) + { + modifier = current; + } + if (modifier <= bestModifier) + continue; + bestModifier = modifier; + selected = item.ObjectId; + } + return selected; + } + + private void ClearDoor() + { + _activeDoorObjectId = 0u; + _activeLockpickObjectId = 0u; + _doorElapsed = 0d; + _doorRetryElapsed = 0d; + } + + private bool TickCheckpoint( + INavigationAutomation navigation, + in PluginNavigationSnapshot snapshot, + RouteWaypoint waypoint, + double elapsedSeconds) + { + double liveDistance = snapshot.Position.HorizontalDistanceMeters( + waypoint.Position); + if (liveDistance > BoundedMaximumDistance()) + { + StopMovement(); + _checkpointElapsed = 0d; + _status = $"Checkpoint is outside NavFarStopRange ({liveDistance:0.0}m)."; + return false; + } + if (liveDistance > BoundedMinimumDistance()) + { + _checkpointElapsed = 0d; + _status = $"Checkpoint {_index + 1}/{_settings.Waypoints.Count}: {liveDistance:0.0}m"; + return Steer( + navigation, + snapshot.Position, + waypoint.Position, + liveDistance); + } + + StopMovement(); + PluginNavigationPosition confirmed = snapshot.ConfirmedPositionRevision == 0UL + ? snapshot.Position + : snapshot.ConfirmedPosition; + double confirmedDistance = confirmed.HorizontalDistanceMeters( + waypoint.Position); + if (confirmedDistance <= BoundedMinimumDistance()) + { + _checkpointElapsed = 0d; + AdvanceWaypoint(); + return true; + } + + _checkpointElapsed += elapsedSeconds; + _status = $"Checkpoint: waiting for server ({confirmedDistance:0.0}m)."; + if (_checkpointElapsed >= CheckpointRetrySeconds) + { + _checkpointElapsed = 0d; + _hadMovementIntent = navigation.SetMovementIntent( + new PluginMovementIntent(Forward: true, Run: false)) + == PluginNavigationCommandStatus.Accepted; + _status = "Checkpoint: nudging for server confirmation."; + } + return true; + } + + private bool Steer( + INavigationAutomation navigation, + in PluginNavigationPosition current, + in PluginNavigationPosition target, + double distanceMeters) + { + float desired = DesiredHeading(current, target); + float delta = SignedHeadingDelta(current.HeadingDegrees, desired); + float absolute = Math.Abs(delta); + bool turnRight = delta > HeadingToleranceDegrees; + bool turnLeft = delta < -HeadingToleranceDegrees; + bool forward = absolute <= HeadingToleranceDegrees + || (distanceMeters > NearTargetMeters + ? absolute <= FarMovingTurnLimitDegrees + : absolute <= NearMovingTurnLimitDegrees); + var intent = new PluginMovementIntent( + Forward: forward, + TurnLeft: turnLeft, + TurnRight: turnRight, + Run: true); + PluginNavigationCommandStatus result = + navigation.SetMovementIntent(intent); + _hadMovementIntent = result == PluginNavigationCommandStatus.Accepted; + return _hadMovementIntent; + } + + private bool TickAction( + RouteWaypoint waypoint, + double elapsedSeconds, + in PluginNavigationSnapshot navigation) + { + if (!ReferenceEquals(_activeAction, waypoint)) + { + ClearAction(); + _activeAction = waypoint; + _useCompletionBaseline = _host.Automation.Items.LastCompletion.Revision; + _chatBaseline = _host.Automation.Chat.CaptureMessages(0) + .Select(static message => message.Sequence) + .DefaultIfEmpty() + .Max(); + } + _actionElapsed += elapsedSeconds; + _retryElapsed += elapsedSeconds; + + switch (waypoint.Type) + { + case RouteWaypointType.Pause: + _status = $"Pause: {Math.Max(0d, waypoint.DurationMilliseconds / 1000d - _actionElapsed):0.0}s"; + if (_actionElapsed * 1000d >= Math.Max(0, waypoint.DurationMilliseconds)) + CompleteAction(); + return true; + + case RouteWaypointType.ChatCommand: + _status = $"Chat command: {waypoint.Text}"; + if (_actionElapsed < ChatInitialDelaySeconds) + return true; + if (!_actionSent) + { + _actionSent = _host.Automation.Chat.Submit(waypoint.Text); + if (!_actionSent) + { + _status = "Chat command was refused."; + return true; + } + } + CompleteAction(); + return true; + + case RouteWaypointType.Recall: + return TickRecall(waypoint, navigation); + + case RouteWaypointType.Portal: + case RouteWaypointType.PortalByName: + case RouteWaypointType.UseNpc: + case RouteWaypointType.OpenVendor: + return TickUse(waypoint, navigation); + + case RouteWaypointType.Jump: + return TickJump(waypoint, elapsedSeconds, navigation); + + default: + CompleteAction(); + return true; + } + } + + private bool TickUse( + RouteWaypoint waypoint, + in PluginNavigationSnapshot navigation) + { + if (waypoint.Type is RouteWaypointType.PortalByName + or RouteWaypointType.UseNpc) + { + bool currentStillExists = waypoint.ObjectId != 0u + && _host.Automation.Navigation.TryGetObject( + waypoint.ObjectId, + out PluginNavigationObject current) + && (string.IsNullOrWhiteSpace(waypoint.ObjectName) + || current.Name.Equals( + waypoint.ObjectName, + StringComparison.OrdinalIgnoreCase)); + if (!currentStillExists) + { + if (!_host.Automation.Navigation.TryFindObject( + waypoint.ObjectName, + waypoint.Position, + ObjectReacquireRadiusMeters, + out PluginNavigationObject reacquired)) + { + _status = $"Finding {waypoint.ObjectName}."; + if (_actionElapsed >= PortalTimeoutSeconds) + CompleteAction(); + return true; + } + waypoint.ObjectId = reacquired.ObjectId; + _actionSent = false; + _retryElapsed = UseRetrySeconds; + } + } + if (waypoint.Type == RouteWaypointType.OpenVendor + && waypoint.ObjectId != 0u + && _host.Automation.Items.ActiveVendorObjectId == waypoint.ObjectId) + { + CompleteAction(); + return true; + } + if (waypoint.ObjectId == 0u) + { + _status = "Waypoint object is unavailable; continuing."; + CompleteAction(); + return true; + } + + if ((waypoint.Type is RouteWaypointType.Portal + or RouteWaypointType.PortalByName) + && _host.Automation.Navigation.TryGetObject( + waypoint.ObjectId, + out PluginNavigationObject portal)) + { + double distance = navigation.Position.HorizontalDistanceMeters( + portal.Position); + if (distance > Math.Clamp( + _settings.PortalUseDistanceMeters, + 0.5d, + 50d)) + { + _status = $"Approaching {waypoint.ObjectName} ({distance:0.0}m)."; + return Steer( + _host.Automation.Navigation, + navigation.Position, + portal.Position, + distance); + } + } + + if (waypoint.Type == RouteWaypointType.UseNpc + && HasNpcResponse(waypoint.ObjectName)) + { + CompleteAction(); + return true; + } + + PluginItemUseCompletion completion = + _host.Automation.Items.LastCompletion; + if (_actionSent + && completion.Revision > _useCompletionBaseline + && completion.SourceObjectId == waypoint.ObjectId) + { + _useCompletionBaseline = completion.Revision; + if (!completion.IsSuccess) + { + _actionSent = false; + _retryElapsed = UseRetrySeconds; + } + } + + if (navigation.IsPortalSpace) + _sawPortalSpace = true; + if (_sawPortalSpace && !navigation.IsPortalSpace) + { + if (waypoint.Type == RouteWaypointType.PortalByName + && _hasPortalOrigin + && navigation.Position.HorizontalDistanceMeters(_portalOrigin) + <= PortalExitDistanceMeters) + { + _sawPortalSpace = false; + _actionSent = false; + _retryElapsed = UseRetrySeconds; + _status = "Portal exit stayed near its origin; retrying."; + return true; + } + CompleteAction(); + return true; + } + if (_actionElapsed >= PortalTimeoutSeconds) + { + _status = $"Use timed out: {waypoint.ObjectName}."; + CompleteAction(); + return true; + } + if (!_actionSent || _retryElapsed >= UseRetrySeconds) + { + PluginItemCommandResult result = + _host.Automation.Items.Use(waypoint.ObjectId); + _actionSent |= result.Accepted; + if (result.Accepted && !_hasPortalOrigin) + { + _portalOrigin = navigation.Position; + _hasPortalOrigin = true; + } + _retryElapsed = 0d; + _status = result.Accepted + ? $"Using {waypoint.ObjectName}." + : $"Waiting to use {waypoint.ObjectName}."; + } + return true; + } + + private bool HasNpcResponse(string npcName) + { + IReadOnlyList messages = + _host.Automation.Chat.CaptureMessages(_chatBaseline); + foreach (PluginChatMessage message in messages) + { + _chatBaseline = Math.Max(_chatBaseline, message.Sequence); + if (message.Sender.Equals(npcName, StringComparison.OrdinalIgnoreCase) + || message.Text.StartsWith( + npcName + " tells you, ", + StringComparison.OrdinalIgnoreCase) + || message.Text.StartsWith( + npcName + " gives you", + StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + + private bool TickRecall( + RouteWaypoint waypoint, + in PluginNavigationSnapshot navigation) + { + if (navigation.IsPortalSpace) + _sawPortalSpace = true; + if (_sawPortalSpace && !navigation.IsPortalSpace) + { + if (!_hasPortalOrigin + || navigation.Position.HorizontalDistanceMeters(_portalOrigin) + > RecallExitDistanceMeters) + { + CompleteAction(); + return true; + } + _sawPortalSpace = false; + _actionSent = false; + _retryElapsed = UseRetrySeconds; + } + if (_actionElapsed >= PortalTimeoutSeconds) + { + _status = "Recall timed out; continuing route."; + CompleteAction(); + return true; + } + if (!_actionSent || _retryElapsed >= UseRetrySeconds) + { + bool accepted = SubmitRecall(waypoint); + _actionSent |= accepted; + if (accepted && !_hasPortalOrigin) + { + _portalOrigin = navigation.Position; + _hasPortalOrigin = true; + } + _retryElapsed = 0d; + } + _status = $"Recall: {waypoint.RecallLabel}."; + return true; + } + + private bool SubmitRecall(RouteWaypoint waypoint) + { + if (waypoint.RecallSpellId != 0u) + return _host.Automation.Magic.Cast(waypoint.RecallSpellId); + + RouteRecallKind recall = waypoint.Recall; + string? command = recall switch + { + RouteRecallKind.Lifestone => "/lifestone", + RouteRecallKind.Marketplace => "/marketplace", + _ => null, + }; + if (command is not null) + return _host.Automation.Chat.Submit(command); + + string needle = recall == RouteRecallKind.PrimaryPortal + ? "Primary Portal Recall" + : "Secondary Portal Recall"; + PluginSpellInfo? spell = _host.Automation.Spells.KnownSelfBuffs + .FirstOrDefault(value => value.Name.Equals( + needle, + StringComparison.OrdinalIgnoreCase)); + return spell is { SpellId: not 0u } found + && _host.Automation.Magic.Cast(found.SpellId); + } + + private bool TickJump( + RouteWaypoint waypoint, + double elapsedSeconds, + in PluginNavigationSnapshot navigation) + { + if (!_jumpReleased) + { + if (!_jumpAligned) + { + float delta = SignedHeadingDelta( + navigation.Position.HeadingDegrees, + waypoint.JumpHeadingDegrees); + if (Math.Abs(delta) > HeadingToleranceDegrees) + { + PluginMovementIntent turn = new( + TurnLeft: delta < 0f, + TurnRight: delta > 0f, + Run: waypoint.JumpRun); + _hadMovementIntent = _host.Automation.Navigation + .SetMovementIntent(turn) + == PluginNavigationCommandStatus.Accepted; + _status = $"Aligning jump: {Math.Abs(delta):0.0}d."; + return true; + } + _jumpAligned = true; + } + + _jumpChargeElapsed += elapsedSeconds; + bool hold = _jumpChargeElapsed * 1000d + < Math.Max(0, waypoint.JumpChargeMilliseconds); + if (hold) + { + PluginMovementIntent intent = JumpIntent(waypoint, jump: true); + _hadMovementIntent = _host.Automation.Navigation + .SetMovementIntent(intent) + == PluginNavigationCommandStatus.Accepted; + _status = $"Charging jump: {waypoint.JumpChargeMilliseconds}ms."; + return true; + } + PluginMovementIntent release = JumpIntent(waypoint, jump: false); + _ = _host.Automation.Navigation.SetMovementIntent(release); + _jumpReleased = true; + _jumpReleaseElapsed = 0d; + _status = "Jump released."; + return true; + } + _jumpReleaseElapsed += elapsedSeconds; + _jumpSawAirborne |= navigation.IsAirborne; + if (navigation.IsAirborne + || (!_jumpSawAirborne + && _jumpReleaseElapsed < JumpCompletionTimeoutSeconds) + || (_jumpSawAirborne + && _jumpReleaseElapsed < JumpLaunchGraceSeconds)) + return true; + CompleteAction(); + return true; + } + + private static PluginMovementIntent JumpIntent( + RouteWaypoint waypoint, + bool jump) => waypoint.JumpDirection switch + { + RouteJumpDirection.StrafeLeft => new PluginMovementIntent( + StrafeLeft: true, Run: waypoint.JumpRun, Jump: jump), + RouteJumpDirection.StrafeRight => new PluginMovementIntent( + StrafeRight: true, Run: waypoint.JumpRun, Jump: jump), + _ => new PluginMovementIntent( + Forward: true, Run: waypoint.JumpRun, Jump: jump), + }; + + private void CompleteAction() + { + ClearAction(); + AdvanceWaypoint(); + } + + private void AdvanceWaypoint() + { + ClearAction(); + _checkpointElapsed = 0d; + int count = _settings.Waypoints.Count; + if (count == 0) + return; + switch (_settings.Mode) + { + case RouteMode.Circular: + _index = !_reverse + ? (_index + 1) % count + : (_index - 1 + count) % count; + break; + case RouteMode.Linear: + if (!_reverse) + { + _index++; + if (_index >= count) + { + _index = Math.Max(0, count - 1); + _reverse = true; + } + } + else + { + _index--; + if (_index < 0) + { + _index = 0; + _reverse = false; + } + } + break; + case RouteMode.Once: + _settings.Waypoints.RemoveAt(_index); + _index = 0; + _onceComplete = _settings.Waypoints.Count == 0; + break; + } + } + + private void ClearAction() + { + _activeAction = null; + _actionElapsed = 0d; + _retryElapsed = 0d; + _useCompletionBaseline = 0; + _chatBaseline = 0; + _actionSent = false; + _sawPortalSpace = false; + _jumpReleased = false; + _jumpAligned = false; + _jumpSawAirborne = false; + _jumpChargeElapsed = 0d; + _jumpReleaseElapsed = 0d; + _portalOrigin = default; + _hasPortalOrigin = false; + } + + private void StopMovement() + { + if (!_hadMovementIntent) + return; + _ = _host.Automation.Navigation.ClearMovementIntent(); + _hadMovementIntent = false; + } + + private double BoundedMinimumDistance() => Math.Clamp( + _settings.MinimumDistanceMeters, + 0.5d, + 50d); + + private double BoundedMaximumDistance() => Math.Max( + BoundedMinimumDistance(), + _settings.MaximumDistanceMeters); + + internal static float DesiredHeading( + in PluginNavigationPosition from, + in PluginNavigationPosition to) + { + double dx = to.EastWest - from.EastWest; + double dy = to.NorthSouth - from.NorthSouth; + double heading = Math.Atan2(dx, dy) * 180d / Math.PI; + if (heading < 0d) + heading += 360d; + return (float)heading; + } + + internal static float SignedHeadingDelta(float current, float desired) + { + float delta = (desired - current) % 360f; + if (delta > 180f) + delta -= 360f; + else if (delta < -180f) + delta += 360f; + return delta; + } + + internal static double DistanceToSegmentMeters( + in PluginNavigationPosition point, + in PluginNavigationPosition start, + in PluginNavigationPosition end) + { + double dx = end.EastWest - start.EastWest; + double dy = end.NorthSouth - start.NorthSouth; + double lengthSquared = dx * dx + dy * dy; + if (lengthSquared <= double.Epsilon) + return point.HorizontalDistanceMeters(start); + double projection = ((point.EastWest - start.EastWest) * dx + + (point.NorthSouth - start.NorthSouth) * dy) / lengthSquared; + projection = Math.Clamp(projection, 0d, 1d); + double nearestX = start.EastWest + projection * dx; + double nearestY = start.NorthSouth + projection * dy; + double deltaX = point.EastWest - nearestX; + double deltaY = point.NorthSouth - nearestY; + return Math.Sqrt(deltaX * deltaX + deltaY * deltaY) * 240d; + } +} diff --git a/src/AcDream.Plugins.MossTank/PetAutomation.cs b/src/AcDream.Plugins.MossTank/PetAutomation.cs new file mode 100644 index 00000000..b70c5b42 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/PetAutomation.cs @@ -0,0 +1,315 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal enum PetAutomationActionKind +{ + None, + Refill, + Summon, +} + +internal readonly record struct PetAutomationChoice( + PetAutomationActionKind Kind, + PluginInventoryItem Device, + PluginInventoryItem Tool, + PluginCombatTarget Target, + MonsterDamageType DamageType) +{ + public static PetAutomationChoice None => default; +} + +/// +/// VTank combat-pet policy. The host still owns inventory, item use and the +/// spawned pet; this type only chooses a device and waits for the exact +/// server UseDone receipt. +/// +internal sealed class PetAutomation +{ + private const double RetailPetCooldownSeconds = 45d; + private const double RefusalRetrySeconds = 1d; + + private long _observedCompletionRevision; + private uint _pendingSourceId; + private PetAutomationActionKind _pendingKind; + private double _nextSummonAt; + private double _nextRefillAt; + + public bool Tick( + IItemAutomation automation, + ICharacterInfo character, + IReadOnlyList targets, + CombatSettings settings, + double now, + out string status) + { + ArgumentNullException.ThrowIfNull(automation); + ArgumentNullException.ThrowIfNull(character); + ArgumentNullException.ThrowIfNull(targets); + ArgumentNullException.ThrowIfNull(settings); + + ObserveCompletion(automation.LastCompletion, now, out string? completion); + if (completion is not null) + status = completion; + else + status = string.Empty; + + if (_pendingSourceId != 0u) + { + status = _pendingKind == PetAutomationActionKind.Refill + ? "Refilling combat pet" + : "Summoning combat pet"; + return true; + } + if (!settings.SummonPets || !automation.IsAvailable) + return false; + if (automation.IsBusy) + { + status = "Waiting to use combat pet"; + return true; + } + + IReadOnlyList items = automation.CaptureOwnedItems(); + PetAutomationChoice choice = Select( + items, + targets, + character, + settings, + automation.ActiveOwnedPetCount, + now >= _nextRefillAt, + now >= _nextSummonAt); + if (choice.Kind == PetAutomationActionKind.None) + return false; + + PluginItemCommandResult result = choice.Kind == PetAutomationActionKind.Refill + ? automation.Apply(choice.Tool.ObjectId, choice.Device.ObjectId) + : automation.Use(choice.Device.ObjectId); + if (result.Status == PluginItemCommandStatus.Started) + { + _pendingSourceId = choice.Kind == PetAutomationActionKind.Refill + ? choice.Tool.ObjectId + : choice.Device.ObjectId; + _pendingKind = choice.Kind; + status = choice.Kind == PetAutomationActionKind.Refill + ? $"Refilling {choice.Device.Name}" + : $"Summoning {choice.Device.Name} for {choice.Target.Name}"; + return true; + } + + if (choice.Kind == PetAutomationActionKind.Refill) + _nextRefillAt = now + RefusalRetrySeconds; + else + _nextSummonAt = now + RefusalRetrySeconds; + status = result.Notice + ?? $"Combat pet action refused: {result.Status}"; + return true; + } + + internal static PetAutomationChoice Select( + IReadOnlyList items, + IReadOnlyList targets, + ICharacterInfo character, + CombatSettings settings, + int activeOwnedPetCount, + bool allowRefill, + bool allowSummon) + { + if (!settings.SummonPets || activeOwnedPetCount > 0) + return PetAutomationChoice.None; + + float range = settings.PetRangeMode == PetRangeMode.Custom + ? settings.PetCustomRange + : settings.MaximumRange; + int density = Math.Max(1, settings.PetMonsterDensity); + var eligible = new List<(PluginCombatTarget Target, ResolvedMonsterRule Rule)>(); + foreach (PluginCombatTarget target in targets) + { + if (target.Distance > range) + continue; + ResolvedMonsterRule rule = settings.ResolveRule(target); + if (rule.Priority < 0 + || rule.Actions.PetDamageType == MonsterDamageType.None) + { + continue; + } + eligible.Add((target, rule)); + } + if (eligible.Count < density) + return PetAutomationChoice.None; + + eligible.Sort(static (left, right) => + { + int priority = right.Rule.Priority.CompareTo(left.Rule.Priority); + return priority != 0 + ? priority + : left.Target.Distance.CompareTo(right.Target.Distance); + }); + (PluginCombatTarget selectedTarget, ResolvedMonsterRule targetRule) = eligible[0]; + MonsterDamageType desired = ResolveDesiredDamage(targetRule.Actions); + + PluginInventoryItem? device = SelectDevice( + items, + character, + desired, + settings, + allowFallback: targetRule.Actions.PetDamageType + == MonsterDamageType.PlayerAuto); + if (device is not { } selected) + return PetAutomationChoice.None; + + int refillThreshold = Math.Max(0, settings.PetRefillCountNormal); + if (allowRefill + && selected.MaximumStructure > 0 + && selected.Structure <= refillThreshold + && selected.Structure < selected.MaximumStructure + && FindSpirit(items) is { } spirit) + { + return new PetAutomationChoice( + PetAutomationActionKind.Refill, + selected, + spirit, + selectedTarget, + desired); + } + if (!allowSummon || selected.Structure <= 0) + return PetAutomationChoice.None; + return new PetAutomationChoice( + PetAutomationActionKind.Summon, + selected, + default, + selectedTarget, + desired); + } + + private static MonsterDamageType ResolveDesiredDamage( + MonsterRuleActions actions) + { + if (actions.PetDamageType != MonsterDamageType.PlayerAuto) + return actions.PetDamageType; + return actions.DamageType is + MonsterDamageType.Bludgeon or MonsterDamageType.Acid + or MonsterDamageType.Fire or MonsterDamageType.Cold + or MonsterDamageType.Electric + ? actions.DamageType + : MonsterDamageType.Auto; + } + + private static PluginInventoryItem? SelectDevice( + IReadOnlyList items, + ICharacterInfo character, + MonsterDamageType desired, + CombatSettings settings, + bool allowFallback) + { + PluginInventoryItem? exact = null; + PluginInventoryItem? fallback = null; + foreach (PluginInventoryItem item in items) + { + if (!settings.CombatItemObjectIds.Contains(item.ObjectId) + && !settings.CombatItemNames.Contains(item.Name)) + { + continue; + } + if (!item.IsPetDevice || !CanUse(item, character)) + continue; + MonsterDamageType damage = PetDeviceCatalog.DamageType( + item.WeenieClassId); + if (fallback is null || Better(item, fallback.Value)) + fallback = item; + if (desired != MonsterDamageType.Auto && damage != desired) + continue; + if (exact is null || Better(item, exact.Value)) + exact = item; + } + if (exact is not null) + return exact; + return desired == MonsterDamageType.Auto || allowFallback + ? fallback + : null; + } + + private static bool CanUse( + in PluginInventoryItem item, + ICharacterInfo character) + { + if (item.SummoningMastery != 0 + && item.SummoningMastery != character.SummoningMastery) + { + return false; + } + if (item.UseRequiresSkill == 0) + return true; + if (!character.TryGetSkill((uint)item.UseRequiresSkill, out PluginSkillInfo skill) + || skill.Current < item.UseRequiresSkillLevel) + { + return false; + } + return item.UseRequiresSkillSpecialized == 0 + || skill.Training == PluginSkillTraining.Specialized; + } + + private static bool Better( + in PluginInventoryItem candidate, + in PluginInventoryItem incumbent) + { + int candidateRating = candidate.GearDamage + + candidate.GearCriticalChance + + candidate.GearCriticalDamage; + int incumbentRating = incumbent.GearDamage + + incumbent.GearCriticalChance + + incumbent.GearCriticalDamage; + if (candidate.UseRequiresSkillLevel != incumbent.UseRequiresSkillLevel) + return candidate.UseRequiresSkillLevel > incumbent.UseRequiresSkillLevel; + if (candidateRating != incumbentRating) + return candidateRating > incumbentRating; + if (candidate.Structure != incumbent.Structure) + return candidate.Structure > incumbent.Structure; + return candidate.ObjectId < incumbent.ObjectId; + } + + private static PluginInventoryItem? FindSpirit( + IReadOnlyList items) + { + foreach (PluginInventoryItem item in items) + { + if (item.WeenieClassId == PetDeviceCatalog.EncapsulatedSpiritWeenieClassId + && item.StackSize > 0) + { + return item; + } + } + return null; + } + + private void ObserveCompletion( + PluginItemUseCompletion completion, + double now, + out string? status) + { + status = null; + if (completion.Revision == 0 + || completion.Revision == _observedCompletionRevision) + { + return; + } + _observedCompletionRevision = completion.Revision; + if (_pendingSourceId == 0u + || completion.SourceObjectId != _pendingSourceId) + { + return; + } + + PetAutomationActionKind completed = _pendingKind; + _pendingSourceId = 0u; + _pendingKind = PetAutomationActionKind.None; + if (completed == PetAutomationActionKind.Summon) + _nextSummonAt = now + RetailPetCooldownSeconds; + else + _nextRefillAt = now + RefusalRetrySeconds; + status = completion.IsSuccess + ? completed == PetAutomationActionKind.Summon + ? "Combat pet summoned" + : "Combat pet refilled" + : $"Combat pet failed (0x{completion.WeenieError:X})"; + } +} diff --git a/src/AcDream.Plugins.MossTank/PetDeviceCatalog.cs b/src/AcDream.Plugins.MossTank/PetDeviceCatalog.cs new file mode 100644 index 00000000..43f7dde6 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/PetDeviceCatalog.cs @@ -0,0 +1,51 @@ +namespace AcDream.Plugins.MossTank; + +/// +/// End-of-retail combat-pet device element table. The device WCIDs and damage +/// types are the server content mapping consumed by PetDevice; keeping the data +/// here lets MossTank implement VTank's PetDmg column without guessing from an +/// item's localized name. +/// +internal static class PetDeviceCatalog +{ + public const uint EncapsulatedSpiritWeenieClassId = 49485u; + + public static MonsterDamageType DamageType(uint deviceWeenieClassId) => + deviceWeenieClassId switch + { + 48878u or 48880u or 48882u or 48884u or 48886u or 48888u or 48890u => MonsterDamageType.Bludgeon, + 48972u or 49213u or 49214u or 49215u or 49216u or 49217u or 49218u or 49219u + or 49234u or 49235u or 49236u or 49237u or 49238u or 49239u or 49261u or 49262u + or 49263u or 49264u or 49265u or 49266u or 49267u or 49282u or 49283u or 49284u + or 49285u or 49286u or 49287u or 49288u or 49310u or 49311u or 49312u or 49313u + or 49314u or 49315u or 49316u or 49338u or 49339u or 49340u or 49341u or 49342u + or 49343u or 49344u or 49366u or 49367u or 49368u or 49369u or 49370u or 49371u + or 49372u or 49421u or 49422u or 49423u or 49424u or 49425u or 49426u or 49427u + or 49524u or 49525u or 49526u or 49527u or 49528u or 49529u or 49530u => MonsterDamageType.Acid, + 48942u or 48944u or 48945u or 48946u or 48947u or 48948u or 48956u or 48957u + or 48959u or 48961u or 48963u or 48965u or 48967u or 48969u or 49247u or 49248u + or 49249u or 49250u or 49251u or 49252u or 49253u or 49296u or 49297u or 49298u + or 49299u or 49300u or 49301u or 49302u or 49324u or 49325u or 49326u or 49327u + or 49328u or 49329u or 49330u or 49352u or 49353u or 49354u or 49355u or 49356u + or 49357u or 49358u or 49380u or 49381u or 49382u or 49383u or 49384u or 49385u + or 49386u or 49435u or 49436u or 49437u or 49438u or 49439u or 49440u or 49441u + or 49531u or 49532u or 49533u or 49534u or 49535u or 49536u or 49537u => MonsterDamageType.Fire, + 49212u or 49227u or 49228u or 49229u or 49230u or 49231u or 49232u or 49233u + or 49254u or 49255u or 49256u or 49257u or 49258u or 49259u or 49260u or 49275u + or 49276u or 49277u or 49278u or 49279u or 49280u or 49281u or 49303u or 49304u + or 49305u or 49306u or 49307u or 49308u or 49309u or 49331u or 49332u or 49333u + or 49334u or 49335u or 49336u or 49337u or 49359u or 49360u or 49361u or 49362u + or 49363u or 49364u or 49365u or 49387u or 49388u or 49389u or 49390u or 49391u + or 49392u or 49442u or 49443u or 49444u or 49445u or 49446u or 49447u or 49448u + or 49538u or 49539u or 49540u or 49541u or 49542u or 49543u or 49544u => MonsterDamageType.Cold, + 49220u or 49221u or 49222u or 49223u or 49224u or 49225u or 49226u or 49240u + or 49241u or 49242u or 49243u or 49244u or 49245u or 49246u or 49268u or 49269u + or 49270u or 49271u or 49272u or 49273u or 49274u or 49289u or 49290u or 49291u + or 49292u or 49293u or 49294u or 49295u or 49317u or 49318u or 49319u or 49320u + or 49321u or 49322u or 49323u or 49345u or 49346u or 49347u or 49348u or 49349u + or 49350u or 49351u or 49373u or 49374u or 49375u or 49376u or 49377u or 49378u + or 49379u or 49428u or 49429u or 49430u or 49431u or 49432u or 49433u or 49434u + or 49545u or 49546u or 49547u or 49548u or 49549u or 49550u or 49551u => MonsterDamageType.Electric, + _ => MonsterDamageType.Auto, + }; +} diff --git a/src/AcDream.Plugins.MossTank/ProfileGiveController.cs b/src/AcDream.Plugins.MossTank/ProfileGiveController.cs new file mode 100644 index 00000000..965ff5dc --- /dev/null +++ b/src/AcDream.Plugins.MossTank/ProfileGiveController.cs @@ -0,0 +1,247 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// UtilityBelt-compatible named-profile item giver. It classifies a stable +/// inventory snapshot up front and then submits exactly one canonical give at +/// a time, advancing only after the server completion or the owned-object view +/// confirms that the item left inventory. +/// +internal sealed class ProfileGiveController +{ + private const double GiveTimeoutSeconds = 10d; + private const int MaximumAttemptsPerItem = 5; + + private static readonly PluginItemProperties EmptyProperties = new( + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + new Dictionary()); + + private readonly IPluginHost _host; + private readonly MossTankLootProfileStore _profiles; + private readonly Queue _pending = new(); + private uint _targetObjectId; + private uint _waitingObjectId; + private long _completionRevision; + private double _waitingSeconds; + private int _attempts; + private int _given; + private string _profileName = string.Empty; + private string _targetName = string.Empty; + + public ProfileGiveController( + IPluginHost host, + MossTankLootProfileStore profiles) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _profiles = profiles ?? throw new ArgumentNullException(nameof(profiles)); + } + + public bool IsRunning { get; private set; } + public string Status { get; private set; } = "Item giver idle."; + + public bool TryStart(string? profileName, string? targetName) + { + if (IsRunning || !_host.Automation.IsAvailable) + return false; + + string requestedProfile = profileName?.Trim() ?? string.Empty; + string requestedTarget = targetName?.Trim() ?? string.Empty; + PluginWorldObject target = _host.Automation.Objects.CaptureObjects() + .Where(obj => obj.ObjectClass is PluginObjectClass.Player + or PluginObjectClass.Npc) + .Where(obj => obj.Name.Equals( + requestedTarget, + StringComparison.OrdinalIgnoreCase)) + .Where(obj => obj.ObjectId != _host.Automation.Character.ObjectId) + .OrderBy(obj => DistanceFromPlayer(obj)) + .ThenBy(static obj => obj.ObjectId) + .FirstOrDefault(); + if (target.ObjectId == 0u) + { + Status = $"Item giver target not found: {requestedTarget}."; + return false; + } + + var rules = new List(); + if (!_profiles.TryLoadNamed(requestedProfile, rules)) + { + Status = $"Item giver profile not found: {requestedProfile}."; + return false; + } + + IReadOnlyList owned = + _host.Automation.Items.CaptureOwnedItems(); + _pending.Clear(); + foreach (PluginInventoryItem item in owned + .Where(static item => !item.IsEquipped && item.WielderObjectId == 0u) + .OrderBy(static item => item.ObjectId)) + { + PluginItemProperties properties = _host.Automation.Items + .TryCaptureProperties(item.ObjectId, out PluginItemProperties value) + ? value + : EmptyProperties; + if (MatchesGiveProfile(item, properties, rules)) + _pending.Enqueue(item.ObjectId); + } + + _targetObjectId = target.ObjectId; + _profileName = requestedProfile; + _targetName = target.Name; + _waitingObjectId = 0u; + _attempts = 0; + _given = 0; + _waitingSeconds = 0d; + IsRunning = true; + Status = _pending.Count == 0 + ? $"No items match {_profileName}." + : $"Giving {_pending.Count} item(s) to {_targetName}."; + return true; + } + + public bool Tick(double elapsedSeconds, bool canAct) + { + if (!IsRunning) + return false; + if (!_host.Automation.IsAvailable + || !_host.Automation.Objects.TryGet( + _targetObjectId, + out PluginWorldObject target) + || target.ObjectClass is not (PluginObjectClass.Player + or PluginObjectClass.Npc)) + { + Stop("Item giver stopped: target vanished."); + return false; + } + + if (_waitingObjectId != 0u) + { + _waitingSeconds += Math.Max(0d, elapsedSeconds); + PluginInventoryCompletion completion = + _host.Automation.Items.LastInventoryCompletion; + bool itemStillOwned = _host.Automation.Items.CaptureOwnedItems() + .Any(item => item.ObjectId == _waitingObjectId); + if (!itemStillOwned + || (completion.Revision > _completionRevision + && completion.Kind == PluginInventoryCommandKind.Give + && completion.SourceObjectId == _waitingObjectId)) + { + if (!itemStillOwned || completion.IsSuccess) + _given++; + _pending.Dequeue(); + _waitingObjectId = 0u; + _attempts = 0; + _waitingSeconds = 0d; + } + else if (_waitingSeconds >= GiveTimeoutSeconds) + { + if (_attempts >= MaximumAttemptsPerItem) + { + _pending.Dequeue(); + _waitingObjectId = 0u; + _attempts = 0; + _waitingSeconds = 0d; + } + else + { + _waitingObjectId = 0u; + _waitingSeconds = 0d; + } + } + return true; + } + + if (_pending.Count == 0) + { + Stop($"Item giver finished: {_given} item(s) given to {_targetName}."); + return false; + } + if (!canAct || _host.Automation.Items.IsBusy) + return true; + + uint objectId = _pending.Peek(); + if (!_host.Automation.Items.CaptureOwnedItems() + .Any(item => item.ObjectId == objectId)) + { + _pending.Dequeue(); + return true; + } + + long baselineRevision = + _host.Automation.Items.LastInventoryCompletion.Revision; + PluginItemCommandResult result = _host.Automation.Items.Give( + objectId, + _targetObjectId); + if (result.Accepted) + { + _waitingObjectId = objectId; + _completionRevision = baselineRevision; + _waitingSeconds = 0d; + _attempts++; + Status = $"Giving item {_given + 1} to {_targetName}…"; + } + else if (result.Status is PluginItemCommandStatus.InvalidItem + or PluginItemCommandStatus.InvalidTarget + or PluginItemCommandStatus.Refused + or PluginItemCommandStatus.Unavailable) + { + _pending.Dequeue(); + _attempts = 0; + } + return true; + } + + public void Reset() + { + _pending.Clear(); + _targetObjectId = 0u; + _waitingObjectId = 0u; + _completionRevision = 0; + _waitingSeconds = 0d; + _attempts = 0; + _given = 0; + IsRunning = false; + Status = "Item giver idle."; + } + + private bool MatchesGiveProfile( + in PluginInventoryItem item, + in PluginItemProperties properties, + IReadOnlyList rules) + { + foreach (LootRule rule in rules) + { + if (!rule.IsMatch(item, properties, _host, out _)) + continue; + return rule.Action is LootAction.Keep or LootAction.KeepUpTo; + } + return false; + } + + private double DistanceFromPlayer(in PluginWorldObject target) + { + PluginNavigationSnapshot player = _host.Automation.Navigation.Snapshot; + if (!player.IsAvailable || !target.HasPosition) + return double.MaxValue; + double dx = target.Position.NorthSouth - player.Position.NorthSouth; + double dy = target.Position.EastWest - player.Position.EastWest; + return Math.Sqrt((dx * dx) + (dy * dy)); + } + + private void Stop(string status) + { + _pending.Clear(); + _targetObjectId = 0u; + _waitingObjectId = 0u; + _completionRevision = 0; + _waitingSeconds = 0d; + _attempts = 0; + IsRunning = false; + Status = status; + } +} diff --git a/src/AcDream.Plugins.MossTank/SpellComponentPolicy.cs b/src/AcDream.Plugins.MossTank/SpellComponentPolicy.cs new file mode 100644 index 00000000..dda867cb --- /dev/null +++ b/src/AcDream.Plugins.MossTank/SpellComponentPolicy.cs @@ -0,0 +1,73 @@ +using System.Globalization; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// VTank's BlacklistedSpellComps gate over the component metadata projected by +/// the host. The legacy setting serializes component id/name pairs, so both +/// forms are accepted for imported profiles. +/// +internal static class SpellComponentPolicy +{ + public static bool UsesBlacklistedComponent( + ISpellCatalog catalog, + in PluginSpellInfo spell, + string setting) + { + if (string.IsNullOrWhiteSpace(setting) + || spell.FormulaComponentIds.Count == 0) + { + return false; + } + foreach (uint componentId in spell.FormulaComponentIds) + { + if (ContainsNumber(setting, componentId)) + return true; + if (!catalog.TryGetComponent( + componentId, + out PluginSpellComponentInfo component)) + { + continue; + } + if (component.Name.Length != 0 + && setting.Contains( + component.Name, + StringComparison.OrdinalIgnoreCase)) + { + return true; + } + if (ContainsNumber(setting, component.WeenieClassId)) + return true; + } + return false; + } + + private static bool ContainsNumber(string setting, uint value) + { + if (value == 0u) + return false; + string decimalText = value.ToString(CultureInfo.InvariantCulture); + string hexText = value.ToString("X", CultureInfo.InvariantCulture); + return ContainsDelimited(setting, decimalText) + || setting.Contains("0x" + hexText, StringComparison.OrdinalIgnoreCase); + } + + private static bool ContainsDelimited(string text, string token) + { + int start = 0; + while ((start = text.IndexOf( + token, + start, + StringComparison.OrdinalIgnoreCase)) >= 0) + { + int end = start + token.Length; + bool left = start == 0 || !char.IsDigit(text[start - 1]); + bool right = end == text.Length || !char.IsDigit(text[end]); + if (left && right) + return true; + start = end; + } + return false; + } +} diff --git a/src/AcDream.Plugins.MossTank/VitalPlan.cs b/src/AcDream.Plugins.MossTank/VitalPlan.cs index 1062baef..637b3f68 100644 --- a/src/AcDream.Plugins.MossTank/VitalPlan.cs +++ b/src/AcDream.Plugins.MossTank/VitalPlan.cs @@ -2,91 +2,184 @@ using AcDream.Plugin.Abstractions; namespace AcDream.Plugins.MossTank; -/// What MossTank wants to do about the character's vitals right now. +internal enum VitalKind +{ + Health = 2, + Stamina = 4, + Mana = 6, +} + +/// What the legacy conversion-only helper wants to do. public enum VitalAction { None = 0, - /// Convert stamina into mana. StaminaToMana, - /// Restore stamina, so stamina-to-mana has something to convert. Revitalize, } -/// Thresholds for vital upkeep, following VTank's Recharge-* settings. +/// +/// VTank's nine Recharge-* sliders and its recharge-handler options. +/// Values are normalized 0..1 at the plugin/UI seam; VTank stores percentages. +/// public sealed class VitalSettings { - /// - /// Whether to convert vitals at all. VTank does this by default through its - /// Recharge-* thresholds, but it is surprising the first time a buff pass - /// spends your stamina, so it is worth being able to turn off. - /// public bool Enabled { get; set; } = true; - /// Convert stamina to mana below this fraction of max mana. - public double ManaFloor { get; set; } = 0.50; + // defaultsettings.usd, verbatim. + public double NormalHealth { get; set; } = 0.75; + public double NormalStamina { get; set; } = 0.50; + public double NormalMana { get; set; } = 0.50; + public double NoTargetHealth { get; set; } = 0.01; + public double NoTargetStamina { get; set; } = 0.01; + public double NoTargetMana { get; set; } = 0.01; + public double HelperHealth { get; set; } = 0.20; + public double HelperStamina { get; set; } = 0.01; + public double HelperMana { get; set; } = 0.01; + public float HelperHealthDistance { get; set; } = 59.6f; + public float HelperStaminaDistance { get; set; } = 59.6f; + public float HelperManaDistance { get; set; } = 32f; - /// Stop converting once mana is back above this fraction. - public double ManaTarget { get; set; } = 0.85; + public bool HelpOthers { get; set; } = true; + public bool UseHealersHeart { get; set; } = true; + public double RechargeBoostTimeSeconds { get; set; } = 5d; + public int RechargeBoostAmount { get; set; } = 40; + public bool ClearLevelBoostFlagOnCast { get; set; } = true; + public int DropToPeaceModeRetryCount { get; set; } = 34; + public string RechargeHandlerSet { get; set; } = "RechargeHandlerSet"; + public bool UseKitsInMagicMode { get; set; } = true; + public bool GoToPeaceModeToUseKits { get; set; } + public int MinimumHealKitSuccessChance { get; set; } = 95; + public double StaminaToHealthMultiplier { get; set; } = 1.9; + public double ManaToHealthMultiplier { get; set; } = 2.8; + public bool CastDispelSelf { get; set; } + public bool UseDispelItems { get; set; } + public bool UseDispelDrum { get; set; } - /// Refuse to drain stamina below this fraction — the conversion - /// takes half your stamina, and stranding the character at zero is worse - /// than being short of mana. - public double StaminaFloor { get; set; } = 0.35; + // Compatibility aliases for the first MossTank prototype. Keeping them + // avoids breaking plugin-side callers while the implementation now follows + // VTank's actual nine-threshold model. + public double ManaFloor + { + get => NormalMana; + set => NormalMana = Clamp(value); + } + + public double ManaTarget + { + get => NormalMana; + set => NormalMana = Clamp(value); + } + + public double StaminaFloor + { + get => NormalStamina; + set => NormalStamina = Clamp(value); + } + + internal double Threshold(VitalKind vital, bool noTarget) => vital switch + { + VitalKind.Health => noTarget + ? Math.Max(NormalHealth, NoTargetHealth) + : NormalHealth, + VitalKind.Stamina => noTarget + ? Math.Max(NormalStamina, NoTargetStamina) + : NormalStamina, + VitalKind.Mana => noTarget + ? Math.Max(NormalMana, NoTargetMana) + : NormalMana, + _ => 0d, + }; + + internal double NormalThreshold(VitalKind vital) => vital switch + { + VitalKind.Health => NormalHealth, + VitalKind.Stamina => NormalStamina, + VitalKind.Mana => NormalMana, + _ => 0d, + }; + + private static double Clamp(double value) => Math.Clamp(value, 0d, 1d); } -/// -/// Picks the vital-upkeep spell to cast, if any. -/// -/// -/// -/// The loop the user asked for: when mana runs low, convert stamina into mana; -/// when that leaves stamina low, restore stamina with Revitalize, which lets -/// the conversion continue. -/// -/// -/// These spells cannot be identified by family. Retail groups the vital -/// transfers by source vital, so family 89 contains both "Stamina to -/// Health" and "Stamina to Mana", and family 87 both "Health to Mana" and -/// "Health to Stamina". Picking the strongest tier in a family would therefore -/// convert into the wrong vital roughly half the time. They are identified by -/// their retail name stem instead, which is stable and comes from the same -/// spell table. -/// -/// +/// Pure retail threshold and spell-selection policy. public static class VitalPlan { + public const string HealSelfStem = "Heal Self"; public const string StaminaToManaStem = "Stamina to Mana"; public const string RevitalizeStem = "Revitalize"; - public static VitalAction Decide(ICharacterInfo character, VitalSettings settings) + internal static VitalKind? DecideNeed( + ICharacterInfo character, + VitalSettings settings, + bool noTarget, + int healthCurrentAdjustment = 0, + int staminaCurrentAdjustment = 0, + int manaCurrentAdjustment = 0) { if (!settings.Enabled) - return VitalAction.None; + return null; - double mana = Fraction(character.CurrentMana, character.MaxMana); - double stamina = Fraction(character.CurrentStamina, character.MaxStamina); - - // Unknown vitals (no session, or nothing published yet) must not be - // read as "empty" — that would cast on a character that is fine. - if (character.MaxMana == 0 || character.MaxStamina == 0) - return VitalAction.None; - - if (mana >= settings.ManaTarget) - return VitalAction.None; - - if (mana < settings.ManaFloor) + // cr.cs checks in this exact order. + foreach (VitalKind vital in new[] + { + VitalKind.Health, + VitalKind.Stamina, + VitalKind.Mana, + }) { - return stamina > settings.StaminaFloor - ? VitalAction.StaminaToMana - : VitalAction.Revitalize; + (uint current, uint maximum) = Read(character, vital); + if (maximum == 0u) + continue; + int adjustment = vital switch + { + VitalKind.Health => healthCurrentAdjustment, + VitalKind.Stamina => staminaCurrentAdjustment, + VitalKind.Mana => manaCurrentAdjustment, + _ => 0, + }; + current = adjustment <= 0 + ? current + : (uint)Math.Max(0L, (long)current - adjustment); + if ((double)current / maximum < settings.Threshold(vital, noTarget)) + return vital; } + return null; + } - return VitalAction.None; + internal static bool IsBelowNormal( + ICharacterInfo character, + VitalSettings settings, + VitalKind vital) + { + (uint current, uint maximum) = Read(character, vital); + return maximum != 0u + && (double)current / maximum < settings.NormalThreshold(vital); + } + + internal static int Percent(ICharacterInfo character, VitalKind vital) + { + (uint current, uint maximum) = Read(character, vital); + return maximum == 0u ? 100 : (int)(100u * current / maximum); } /// - /// The strongest castable spell whose name contains . + /// Compatibility helper for the original MossTank mana-conversion tests. + /// The executable controller now uses . /// + public static VitalAction Decide(ICharacterInfo character, VitalSettings settings) + { + if (!settings.Enabled || character.MaxMana == 0 || character.MaxStamina == 0) + return VitalAction.None; + double mana = (double)character.CurrentMana / character.MaxMana; + if (mana >= settings.NormalMana) + return VitalAction.None; + double stamina = (double)character.CurrentStamina / character.MaxStamina; + return stamina > settings.NormalStamina + ? VitalAction.StaminaToMana + : VitalAction.Revitalize; + } + + /// The strongest castable learned spell whose name contains a stem. public static bool TryFind( IReadOnlyList known, string stem, @@ -96,7 +189,6 @@ public static class VitalPlan { pick = default; bool found = false; - foreach (PluginSpellInfo spell in known) { if (spell.Name.IndexOf(stem, StringComparison.OrdinalIgnoreCase) < 0) @@ -107,7 +199,9 @@ public static class VitalPlan { continue; } - if (!found || spell.Tier > pick.Tier) + if (!found + || spell.Quality > pick.Quality + || (spell.Quality == pick.Quality && spell.Tier > pick.Tier)) { pick = spell; found = true; @@ -116,6 +210,13 @@ public static class VitalPlan return found; } - private static double Fraction(uint current, uint max) => - max == 0 ? 1.0 : (double)current / max; + private static (uint Current, uint Maximum) Read( + ICharacterInfo character, + VitalKind vital) => vital switch + { + VitalKind.Health => (character.CurrentHealth, character.MaxHealth), + VitalKind.Stamina => (character.CurrentStamina, character.MaxStamina), + VitalKind.Mana => (character.CurrentMana, character.MaxMana), + _ => (0u, 0u), + }; } diff --git a/src/AcDream.Plugins.MossTank/VitalRecharge.cs b/src/AcDream.Plugins.MossTank/VitalRecharge.cs new file mode 100644 index 00000000..9785a94f --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VitalRecharge.cs @@ -0,0 +1,1103 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal enum VitalRechargeSourceKind +{ + LearnedSpell, + CasterItem, + Kit, + Food, +} + +internal readonly record struct VitalRechargeChoice( + VitalKind Vital, + VitalRechargeSourceKind SourceKind, + string Name, + uint SpellId, + uint ItemObjectId, + PluginCombatMode? RequiredMode) +{ + public bool UsesItem => SourceKind != VitalRechargeSourceKind.LearnedSpell; + public uint TargetObjectId { get; init; } +} + +internal enum VitalRechargeMethod +{ + RegularSpell, + StaminaToHealth, + ManaToHealth, + HealthToStamina, + HealthToMana, + Kit, + Food, +} + +/// +/// VTank's default RechargeHandlerSet, including its stance- and +/// current-percentage-dependent order. The host supplies raw inventory and +/// spell data; this class owns all policy. +/// +internal static class VitalRechargePlanner +{ + private const uint HealingSkill = 21u; + private const uint CasterItemType = 0x00008000u; + + public static bool TryPlan( + VitalKind vital, + IAutomationSurface automation, + VitalSettings settings, + CombatSettings combatSettings, + out VitalRechargeChoice choice) + { + ArgumentNullException.ThrowIfNull(automation); + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(combatSettings); + + PluginCombatMode mode = automation.Combat.Snapshot.Mode; + int percent = VitalPlan.Percent(automation.Character, vital); + IReadOnlyList handlers = Handlers( + vital, + mode == PluginCombatMode.Magic, + percent, + settings.RechargeHandlerSet); + IReadOnlyList items = + automation.Items.CaptureOwnedItems(); + + foreach (VitalRechargeMethod handler in handlers) + { + if (TryHandler( + handler, + vital, + automation, + settings, + combatSettings, + items, + out choice)) + { + return true; + } + } + choice = default; + return false; + } + + public static bool TryPlanHelper( + IAutomationSurface automation, + VitalSettings settings, + out VitalRechargeChoice choice) => TryPlanHelper( + automation, + settings, + new CombatSettings(), + out choice); + + public static bool TryPlanHelper( + IAutomationSurface automation, + VitalSettings settings, + CombatSettings combatSettings, + out VitalRechargeChoice choice) + { + ArgumentNullException.ThrowIfNull(automation); + ArgumentNullException.ThrowIfNull(settings); + if (!settings.HelpOthers || !automation.Fellowship.IsInFellowship) + { + choice = default; + return false; + } + + IReadOnlyList members = + automation.Fellowship.CaptureMembers(); + foreach ((VitalKind vital, double threshold, float distance, uint baseSpell) + in new[] + { + (VitalKind.Health, settings.HelperHealth, + settings.HelperHealthDistance, (uint)SpellId.AdjaSGift), + (VitalKind.Stamina, settings.HelperStamina, + settings.HelperStaminaDistance, (uint)SpellId.Replenish), + (VitalKind.Mana, settings.HelperMana, + settings.HelperManaDistance, (uint)SpellId.GiftOfEssence), + }) + { + PluginFellowMember? target = Lowest(members, vital, threshold, distance); + if (target is not { } fellow) + continue; + if (vital == VitalKind.Health + && TryHealersHeart( + automation, + settings, + fellow, + out choice)) + { + return true; + } + if (!automation.Spells.TryGet(baseSpell, out PluginSpellInfo basis) + || !TryFindFamily( + automation.Spells.KnownSelfBuffs, + basis.Family, + automation.Character, + automation.Spells, + combatSettings.BlacklistedSpellComponents, + out PluginSpellInfo spell)) + { + continue; + } + + choice = new VitalRechargeChoice( + vital, + VitalRechargeSourceKind.LearnedSpell, + spell.Name, + spell.SpellId, + 0u, + PluginCombatMode.Magic) + { + TargetObjectId = fellow.ObjectId, + }; + return true; + } + choice = default; + return false; + } + + private static bool TryHealersHeart( + IAutomationSurface automation, + VitalSettings settings, + in PluginFellowMember target, + out VitalRechargeChoice choice) + { + choice = default; + if (!settings.UseHealersHeart + || !automation.Character.TryGetSkill(33u, out PluginSkillInfo life) + || life.Current < 245u + || !automation.Character.TryGetSkill(14u, out PluginSkillInfo secondary) + || secondary.Current < 105u) + { + return false; + } + + PluginInventoryItem selected = default; + int rank = 0; + foreach (PluginInventoryItem item in automation.Items.CaptureOwnedItems()) + { + int candidateRank = item.Name switch + { + "Legendary Seed of Mornings" => 2, + "The Healer's Heart" => 1, + _ => 0, + }; + if (candidateRank <= rank) + continue; + selected = item; + rank = candidateRank; + } + if (selected.ObjectId == 0u) + return false; + + choice = new VitalRechargeChoice( + VitalKind.Health, + VitalRechargeSourceKind.CasterItem, + selected.Name, + 0u, + selected.ObjectId, + null) + { + TargetObjectId = target.ObjectId, + }; + return true; + } + + internal static IReadOnlyList Handlers( + VitalKind vital, + bool magicMode, + int currentPercent, + string? handlerSet = null) + { + IReadOnlyList defaults; + if (magicMode) + { + defaults = vital switch + { + VitalKind.Health when currentPercent <= 15 => + [ + VitalRechargeMethod.StaminaToHealth, + VitalRechargeMethod.ManaToHealth, + VitalRechargeMethod.RegularSpell, + VitalRechargeMethod.Food, + VitalRechargeMethod.Kit, + ], + VitalKind.Health => + [ + VitalRechargeMethod.Kit, + VitalRechargeMethod.StaminaToHealth, + VitalRechargeMethod.ManaToHealth, + VitalRechargeMethod.RegularSpell, + VitalRechargeMethod.Food, + ], + VitalKind.Stamina => + [ + VitalRechargeMethod.Kit, + VitalRechargeMethod.RegularSpell, + VitalRechargeMethod.Food, + ], + VitalKind.Mana => + [ + VitalRechargeMethod.Kit, + VitalRechargeMethod.Food, + VitalRechargeMethod.RegularSpell, + ], + _ => [], + }; + } + else + { + defaults = vital switch + { + VitalKind.Health when currentPercent <= 10 => + [ + VitalRechargeMethod.Food, + VitalRechargeMethod.Kit, + VitalRechargeMethod.StaminaToHealth, + VitalRechargeMethod.RegularSpell, + ], + VitalKind.Health when currentPercent <= 15 => + [ + VitalRechargeMethod.Food, + VitalRechargeMethod.Kit, + VitalRechargeMethod.RegularSpell, + ], + VitalKind.Health => + [ + VitalRechargeMethod.Kit, + VitalRechargeMethod.Food, + VitalRechargeMethod.RegularSpell, + ], + VitalKind.Stamina or VitalKind.Mana => + [ + VitalRechargeMethod.Kit, + VitalRechargeMethod.Food, + VitalRechargeMethod.RegularSpell, + ], + _ => [], + }; + } + + return TryParseHandlerSet( + handlerSet, + HandlerContext(vital, magicMode, currentPercent), + out VitalRechargeMethod[] custom) + ? custom + : defaults; + } + + private static string HandlerContext( + VitalKind vital, + bool magicMode, + int currentPercent) + { + string stance = magicMode ? "magic" : "combat"; + string vitalName = vital.ToString().ToLowerInvariant(); + string band = vital == VitalKind.Health + ? magicMode + ? currentPercent <= 15 ? "low" : "normal" + : currentPercent <= 10 + ? "critical" + : currentPercent <= 15 ? "low" : "normal" + : "normal"; + return $"{stance}-{vitalName}-{band}"; + } + + private static bool TryParseHandlerSet( + string? source, + string context, + out VitalRechargeMethod[] handlers) + { + handlers = []; + if (string.IsNullOrWhiteSpace(source) + || source.Equals("RechargeHandlerSet", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string selected = source; + if (source.Contains('=')) + { + selected = string.Empty; + string fallbackContext = context.EndsWith( + "-critical", + StringComparison.Ordinal) + || context.EndsWith("-low", StringComparison.Ordinal) + ? context[..context.LastIndexOf('-')] + "-normal" + : string.Empty; + foreach (string segment in source.Split( + ';', + StringSplitOptions.RemoveEmptyEntries + | StringSplitOptions.TrimEntries)) + { + int equals = segment.IndexOf('='); + if (equals <= 0) + continue; + string key = segment[..equals].Trim(); + if (key.Equals(context, StringComparison.OrdinalIgnoreCase)) + { + selected = segment[(equals + 1)..]; + break; + } + if (selected.Length == 0 + && fallbackContext.Length != 0 + && key.Equals( + fallbackContext, + StringComparison.OrdinalIgnoreCase)) + { + selected = segment[(equals + 1)..]; + } + } + } + if (string.IsNullOrWhiteSpace(selected)) + return false; + + var parsed = new List(); + foreach (string token in selected.Split( + [',', '>', '|'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + string normalized = token.Replace(" ", string.Empty) + .Replace("-", string.Empty) + .ToLowerInvariant(); + VitalRechargeMethod? value = normalized switch + { + "regularspell" => VitalRechargeMethod.RegularSpell, + "staminatohealth" => VitalRechargeMethod.StaminaToHealth, + "manatohealth" => VitalRechargeMethod.ManaToHealth, + "healthtostamina" => VitalRechargeMethod.HealthToStamina, + "healthtomana" => VitalRechargeMethod.HealthToMana, + "kit" or "kitrecharge" => VitalRechargeMethod.Kit, + "food" or "rechargewithfood" => VitalRechargeMethod.Food, + _ => null, + }; + if (value is { } method) + parsed.Add(method); + } + if (parsed.Count == 0) + return false; + handlers = [.. parsed]; + return true; + } + + private static bool TryHandler( + VitalRechargeMethod method, + VitalKind vital, + IAutomationSurface automation, + VitalSettings settings, + CombatSettings combatSettings, + IReadOnlyList items, + out VitalRechargeChoice choice) + { + switch (method) + { + case VitalRechargeMethod.Kit: + return TryKit( + vital, + automation, + settings, + combatSettings, + items, + out choice); + case VitalRechargeMethod.Food: + return TryFood(vital, combatSettings, items, out choice); + } + + (string stem, VitalKind? sourceVital) = SpellStem(method, vital); + if (stem.Length == 0 + || sourceVital is { } source + && vital != VitalKind.Health + && VitalPlan.IsBelowNormal(automation.Character, settings, source)) + { + choice = default; + return false; + } + if (!TrySpell( + vital, + stem, + automation, + combatSettings, + items, + out choice)) + { + return false; + } + if (method is VitalRechargeMethod.StaminaToHealth + or VitalRechargeMethod.ManaToHealth + && !ConversionWorthwhile( + method, + choice.SpellId, + automation, + settings)) + { + choice = default; + return false; + } + return true; + } + + private static (string Stem, VitalKind? SourceVital) SpellStem( + VitalRechargeMethod method, + VitalKind vital) => method switch + { + VitalRechargeMethod.RegularSpell => vital switch + { + VitalKind.Health => (VitalPlan.HealSelfStem, null), + VitalKind.Stamina => (VitalPlan.RevitalizeStem, null), + VitalKind.Mana => (VitalPlan.StaminaToManaStem, VitalKind.Stamina), + _ => (string.Empty, null), + }, + VitalRechargeMethod.StaminaToHealth => + ("Stamina to Health", VitalKind.Stamina), + VitalRechargeMethod.ManaToHealth => + ("Mana to Health", VitalKind.Mana), + VitalRechargeMethod.HealthToStamina => + ("Health to Stamina", VitalKind.Health), + VitalRechargeMethod.HealthToMana => + ("Health to Mana", VitalKind.Health), + _ => (string.Empty, null), + }; + + private static bool TrySpell( + VitalKind vital, + string stem, + IAutomationSurface automation, + CombatSettings settings, + IReadOnlyList items, + out VitalRechargeChoice choice) + { + bool found = false; + PluginSpellInfo bestSpell = default; + uint bestItem = 0u; + + foreach (PluginSpellInfo spell in automation.Spells.KnownSelfBuffs) + { + if (!Matches(spell.Name, stem)) + continue; + if (SpellComponentPolicy.UsesBlacklistedComponent( + automation.Spells, + spell, + settings.BlacklistedSpellComponents)) + continue; + if (!CanCast(automation.Character, spell)) + continue; + if (!found || Better(spell, 0u, bestSpell, bestItem)) + { + found = true; + bestSpell = spell; + bestItem = 0u; + } + } + + foreach (PluginInventoryItem item in items) + { + if (!settings.CombatItemObjectIds.Contains(item.ObjectId) + && !settings.CombatItemNames.Contains(item.Name)) + { + continue; + } + if ((item.ItemType & CasterItemType) == 0u) + continue; + + foreach (uint spellId in ItemSpellIds(item)) + { + if (!automation.Spells.TryGet(spellId, out PluginSpellInfo spell) + || !Matches(spell.Name, stem)) + { + continue; + } + if (SpellComponentPolicy.UsesBlacklistedComponent( + automation.Spells, + spell, + settings.BlacklistedSpellComponents)) + { + continue; + } + if (!found || Better(spell, item.ObjectId, bestSpell, bestItem)) + { + found = true; + bestSpell = spell; + bestItem = item.ObjectId; + } + } + } + + if (!found) + { + choice = default; + return false; + } + choice = new VitalRechargeChoice( + vital, + bestItem == 0u + ? VitalRechargeSourceKind.LearnedSpell + : VitalRechargeSourceKind.CasterItem, + bestSpell.Name, + bestSpell.SpellId, + bestItem, + PluginCombatMode.Magic); + return true; + } + + private static bool TryKit( + VitalKind vital, + IAutomationSurface automation, + VitalSettings settings, + CombatSettings combatSettings, + IReadOnlyList items, + out VitalRechargeChoice choice) + { + PluginCombatMode mode = automation.Combat.Snapshot.Mode; + if (mode == PluginCombatMode.Magic && !settings.UseKitsInMagicMode) + { + choice = default; + return false; + } + if (vital != VitalKind.Stamina && automation.Character.CurrentStamina < 15u) + { + choice = default; + return false; + } + if (!automation.Character.TryGetSkill(HealingSkill, out PluginSkillInfo healing) + || healing.Training is not (PluginSkillTraining.Trained + or PluginSkillTraining.Specialized)) + { + choice = default; + return false; + } + + bool found = false; + PluginInventoryItem best = default; + foreach (PluginInventoryItem item in items) + { + if (!combatSettings.ConsumableNames.Contains(item.Name) + || item.BoosterVital != (int)vital + || item.UseRequiresSkill != (int)HealingSkill + || item.UseRequiresSkillLevel > healing.Current + || item.UseRequiresSkillSpecialized != 0 + && healing.Training != PluginSkillTraining.Specialized + || HealKitChance( + healing.Current, + item.BoostValue, + automation.Character, + vital, + mode) * 100d < settings.MinimumHealKitSuccessChance) + { + continue; + } + if (!found + || item.HealKitModifier > best.HealKitModifier + || item.HealKitModifier == best.HealKitModifier + && item.ObjectId < best.ObjectId) + { + best = item; + found = true; + } + } + if (!found) + { + choice = default; + return false; + } + + choice = new VitalRechargeChoice( + vital, + VitalRechargeSourceKind.Kit, + best.Name, + 0u, + best.ObjectId, + settings.GoToPeaceModeToUseKits + ? PluginCombatMode.Peace + : null); + return true; + } + + private static bool TryFood( + VitalKind vital, + CombatSettings settings, + IReadOnlyList items, + out VitalRechargeChoice choice) + { + foreach (PluginInventoryItem item in items) + { + if (settings.ConsumableNames.Contains(item.Name) + && item.BoosterVital == (int)vital + && item.UseRequiresSkill != (int)HealingSkill) + { + choice = new VitalRechargeChoice( + vital, + VitalRechargeSourceKind.Food, + item.Name, + 0u, + item.ObjectId, + null); + return true; + } + } + choice = default; + return false; + } + + internal static double HealKitChance( + uint healingSkill, + int skillBonus, + ICharacterInfo character, + VitalKind vital, + PluginCombatMode mode) + { + (uint current, uint maximum) = vital switch + { + VitalKind.Health => (character.CurrentHealth, character.MaxHealth), + VitalKind.Stamina => (character.CurrentStamina, character.MaxStamina), + VitalKind.Mana => (character.CurrentMana, character.MaxMana), + _ => (0u, 0u), + }; + double multiplier = mode == PluginCombatMode.Peace ? 2d : 2.2d; + double missing = Math.Max(0d, (double)maximum - current); + double difficulty = Math.Ceiling(multiplier * missing); + return 1d - 1d / (1d + Math.Exp( + 0.03d * (healingSkill + skillBonus - difficulty))); + } + + private static bool ConversionWorthwhile( + VitalRechargeMethod method, + uint conversionSpellId, + IAutomationSurface automation, + VitalSettings settings) + { + if (!automation.Spells.TryGet( + conversionSpellId, + out PluginSpellInfo conversion)) + { + return false; + } + + int ordinaryHeal = 0; + foreach (PluginSpellInfo spell in automation.Spells.KnownSelfBuffs) + { + if (!Matches(spell.Name, VitalPlan.HealSelfStem) + || !CanCast(automation.Character, spell)) + { + continue; + } + ordinaryHeal = Math.Max(ordinaryHeal, EstimatedOrdinaryHeal(spell.Name)); + } + + int missing = checked((int)Math.Max( + 0L, + (long)automation.Character.MaxHealth + - automation.Character.CurrentHealth)); + if (ordinaryHeal > missing) + return false; + + VitalKind source = method == VitalRechargeMethod.StaminaToHealth + ? VitalKind.Stamina + : VitalKind.Mana; + int sourceCurrent = source == VitalKind.Stamina + ? checked((int)automation.Character.CurrentStamina) + : checked((int)automation.Character.CurrentMana) - 30; + sourceCurrent = Math.Max(0, sourceCurrent); + int converted = Math.Min( + missing, + EstimatedTransfer(conversion.Name, sourceCurrent)); + double multiplier = source == VitalKind.Stamina + ? settings.StaminaToHealthMultiplier + : settings.ManaToHealthMultiplier; + return ordinaryHeal * multiplier < missing + && ordinaryHeal * multiplier < converted; + } + + internal static int EstimatedOrdinaryHeal(string spellName) => spellName switch + { + "Heal Self I" => 17, + "Heal Self II" => 25, + "Heal Self III" => 32, + "Heal Self IV" => 45, + "Heal Self V" => 67, + "Heal Self VI" => 87, + "Adja's Intervention" => 115, + "Incantation of Heal Self" => 135, + _ => 10, + }; + + internal static int EstimatedTransfer(string spellName, int sourceCurrent) + { + (double multiplier, int cap) = spellName switch + { + _ when spellName.EndsWith(" I", StringComparison.Ordinal) => + (0.9, 50), + _ when spellName.EndsWith(" II", StringComparison.Ordinal) => + (1.0, 100), + _ when spellName.EndsWith(" III", StringComparison.Ordinal) => + (1.1, 150), + _ when spellName.EndsWith(" IV", StringComparison.Ordinal) => + (1.2, 200), + _ when spellName.EndsWith(" V", StringComparison.Ordinal) => + (1.35, int.MaxValue), + _ when spellName.EndsWith(" VI", StringComparison.Ordinal) => + (1.5, int.MaxValue), + _ => (1.75, int.MaxValue), + }; + return Math.Min( + cap, + checked((int)Math.Floor(sourceCurrent * multiplier))); + } + + private static PluginFellowMember? Lowest( + IReadOnlyList members, + VitalKind vital, + double threshold, + float maximumDistance) + { + PluginFellowMember? best = null; + double bestFraction = double.PositiveInfinity; + foreach (PluginFellowMember member in members) + { + if (member.Distance > maximumDistance) + continue; + (uint current, uint maximum) = vital switch + { + VitalKind.Health => (member.CurrentHealth, member.MaxHealth), + VitalKind.Stamina => (member.CurrentStamina, member.MaxStamina), + VitalKind.Mana => (member.CurrentMana, member.MaxMana), + _ => (0u, 0u), + }; + if (maximum == 0u) + continue; + double fraction = (double)current / maximum; + if (fraction < threshold && fraction < bestFraction) + { + best = member; + bestFraction = fraction; + } + } + return best; + } + + private static bool TryFindFamily( + IReadOnlyList known, + uint family, + ICharacterInfo character, + ISpellCatalog catalog, + string blacklistedComponents, + out PluginSpellInfo pick) + { + pick = default; + bool found = false; + foreach (PluginSpellInfo spell in known) + { + if (spell.Family != family + || SpellComponentPolicy.UsesBlacklistedComponent( + catalog, + spell, + blacklistedComponents) + || !CanCast(character, spell)) + continue; + if (!found + || spell.Quality > pick.Quality + || spell.Quality == pick.Quality && spell.Tier > pick.Tier) + { + pick = spell; + found = true; + } + } + return found; + } + + private static bool CanCast(ICharacterInfo character, PluginSpellInfo spell) => + spell.School == 0u + || !character.TryGetSkill(spell.School, out PluginSkillInfo skill) + || skill.Current >= spell.Difficulty; + + private static bool Better( + PluginSpellInfo candidate, + uint candidateItem, + PluginSpellInfo current, + uint currentItem) + { + int quality = candidate.Quality.CompareTo(current.Quality); + if (quality != 0) + return quality > 0; + // dz.cs/m.cs: a direct learned spell wins the final source tie. + if ((candidateItem == 0u) != (currentItem == 0u)) + return candidateItem == 0u; + return candidateItem < currentItem; + } + + private static bool Matches(string name, string stem) + { + if (name.IndexOf(stem, StringComparison.OrdinalIgnoreCase) >= 0) + return true; + return stem switch + { + VitalPlan.HealSelfStem => name.Equals( + "Adja's Intervention", + StringComparison.OrdinalIgnoreCase), + VitalPlan.RevitalizeStem => name.Equals( + "Robustification", + StringComparison.OrdinalIgnoreCase), + VitalPlan.StaminaToManaStem => name.Equals( + "Meditative Trance", + StringComparison.OrdinalIgnoreCase), + _ => false, + }; + } + + private static IEnumerable ItemSpellIds(PluginInventoryItem item) + { + if (item.SpellId != 0u) + yield return item.SpellId; + foreach (uint spellId in item.AppraisedSpellIds) + { + if (spellId != 0u && spellId != item.SpellId) + yield return spellId; + } + } +} + +/// One server-receipt-driven self-recharge state machine. +internal sealed class VitalRechargeController +{ + private readonly IPluginHost _host; + private readonly VitalSettings _settings; + private readonly CombatSettings _combatSettings; + private Pending? _pending; + private double _retryDelay; + private double _pendingSeconds; + private double _healthBoostRemaining; + private double _staminaBoostRemaining; + private double _manaBoostRemaining; + + public VitalRechargeController( + IPluginHost host, + VitalSettings settings, + CombatSettings combatSettings) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _combatSettings = combatSettings + ?? throw new ArgumentNullException(nameof(combatSettings)); + } + + public string Status { get; private set; } = "Vitals idle"; + + /// True while recharge owns the action slot and combat must pause. + public bool Tick(double elapsedSeconds, bool enabled, bool noTarget) + { + IAutomationSurface automation = _host.Automation; + double elapsed = Math.Max(0d, elapsedSeconds); + _retryDelay = Math.Max(0d, _retryDelay - elapsed); + _healthBoostRemaining = Math.Max(0d, _healthBoostRemaining - elapsed); + _staminaBoostRemaining = Math.Max(0d, _staminaBoostRemaining - elapsed); + _manaBoostRemaining = Math.Max(0d, _manaBoostRemaining - elapsed); + if (!enabled || !_settings.Enabled || !automation.IsAvailable) + { + _pending = null; + ClearBoosts(); + Status = "Vitals idle"; + return false; + } + + if (_pending is { } pending) + { + _pendingSeconds += Math.Max(0d, elapsedSeconds); + if (TryComplete(automation, pending)) + { + if (_settings.ClearLevelBoostFlagOnCast + && pending.Choice.SourceKind + == VitalRechargeSourceKind.LearnedSpell + && IsLevelBoostSpell(pending.Choice)) + { + ClearBoost(pending.Choice.Vital); + } + _pending = null; + _pendingSeconds = 0d; + _retryDelay = 0.25d; + } + else if (_pendingSeconds >= 15d) + { + Status = $"Timed out: {pending.Choice.Name}"; + _pending = null; + _pendingSeconds = 0d; + _retryDelay = 1d; + } + else + { + Status = $"Recharging {pending.Choice.Vital}: {pending.Choice.Name}"; + return true; + } + } + + VitalKind? need = VitalPlan.DecideNeed( + automation.Character, + _settings, + noTarget, + _healthBoostRemaining > 0d ? _settings.RechargeBoostAmount : 0, + _staminaBoostRemaining > 0d ? _settings.RechargeBoostAmount : 0, + _manaBoostRemaining > 0d ? _settings.RechargeBoostAmount : 0); + VitalRechargeChoice helper = default; + if (need is null + && !VitalRechargePlanner.TryPlanHelper( + automation, + _settings, + _combatSettings, + out helper)) + { + Status = "Vitals ready"; + return false; + } + if (_retryDelay > 0d || automation.Magic.IsCasting || automation.Items.IsBusy) + return true; + + VitalRechargeChoice choice; + if (need is null) + { + choice = helper; + } + else if (!VitalRechargePlanner.TryPlan( + need.Value, + automation, + _settings, + _combatSettings, + out choice)) + { + Status = $"No {need.Value} recharge available"; + _retryDelay = 1d; + return false; + } + + if (choice.RequiredMode is { } required + && automation.Combat.Snapshot.Mode != required) + { + if (required == PluginCombatMode.Magic) + ArmBoost(choice.Vital); + PluginCombatCommandResult mode = automation.Combat.EnterMode(required); + Status = mode.Accepted + ? $"Switching to {required} for {choice.Name}" + : $"Waiting for {required}: {choice.Name}"; + return true; + } + + long revision = choice.SourceKind == VitalRechargeSourceKind.LearnedSpell + ? automation.Magic.LastCompletion.Revision + : automation.Items.LastCompletion.Revision; + bool started = Start(automation, choice); + if (!started) + { + Status = $"Waiting to use {choice.Name}"; + _retryDelay = 0.25d; + return true; + } + _pending = new Pending(choice, revision); + _pendingSeconds = 0d; + Status = $"Recharging {choice.Vital}: {choice.Name}"; + return true; + } + + public void Reset() + { + _pending = null; + _pendingSeconds = 0d; + _retryDelay = 0d; + ClearBoosts(); + Status = "Vitals idle"; + } + + private void ArmBoost(VitalKind vital) + { + double duration = Math.Max(0d, _settings.RechargeBoostTimeSeconds); + switch (vital) + { + case VitalKind.Health: + _healthBoostRemaining = duration; + break; + case VitalKind.Stamina: + _staminaBoostRemaining = duration; + break; + case VitalKind.Mana: + _manaBoostRemaining = duration; + break; + } + } + + private void ClearBoost(VitalKind vital) + { + switch (vital) + { + case VitalKind.Health: + _healthBoostRemaining = 0d; + break; + case VitalKind.Stamina: + _staminaBoostRemaining = 0d; + break; + case VitalKind.Mana: + _manaBoostRemaining = 0d; + break; + } + } + + private void ClearBoosts() + { + _healthBoostRemaining = 0d; + _staminaBoostRemaining = 0d; + _manaBoostRemaining = 0d; + } + + private static bool IsLevelBoostSpell(in VitalRechargeChoice choice) => + choice.Vital switch + { + VitalKind.Health => choice.Name.Equals( + "Adja's Intervention", + StringComparison.OrdinalIgnoreCase), + VitalKind.Stamina => choice.Name.Equals( + "Robustification", + StringComparison.OrdinalIgnoreCase), + VitalKind.Mana => choice.Name.Equals( + "Meditative Trance", + StringComparison.OrdinalIgnoreCase), + _ => false, + }; + + private static bool Start( + IAutomationSurface automation, + VitalRechargeChoice choice) + { + if (choice.SourceKind == VitalRechargeSourceKind.LearnedSpell) + { + PluginCastGate gate = choice.TargetObjectId == 0u + ? automation.Magic.EvaluateGate(choice.SpellId) + : automation.Magic.EvaluateGate( + choice.SpellId, + choice.TargetObjectId); + return gate == PluginCastGate.Ready + && (choice.TargetObjectId == 0u + ? automation.Magic.Cast(choice.SpellId) + : automation.Magic.Cast( + choice.SpellId, + choice.TargetObjectId)); + } + + PluginItemCommandResult result = choice.SourceKind switch + { + VitalRechargeSourceKind.Food => + automation.Items.Use(choice.ItemObjectId), + _ => automation.Items.Apply( + choice.ItemObjectId, + choice.TargetObjectId == 0u + ? automation.Character.ObjectId + : choice.TargetObjectId), + }; + return result.Accepted; + } + + private static bool TryComplete(IAutomationSurface automation, Pending pending) + { + if (pending.Choice.SourceKind == VitalRechargeSourceKind.LearnedSpell) + return automation.Magic.LastCompletion.Revision > pending.Revision; + return automation.Items.LastCompletion.Revision > pending.Revision; + } + + private sealed record Pending(VitalRechargeChoice Choice, long Revision); +} diff --git a/src/AcDream.Plugins.MossTank/VtankAmmunitionDatabase.cs b/src/AcDream.Plugins.MossTank/VtankAmmunitionDatabase.cs new file mode 100644 index 00000000..23713c51 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankAmmunitionDatabase.cs @@ -0,0 +1,164 @@ +using System.Globalization; +using System.Reflection; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +internal enum VtankPrismaticAmmoPolicy +{ + Any, + NoPrismatic, + ForcePrismatic, +} + +internal readonly record struct VtankAmmunitionOption( + string Name, + int LauncherType, + int WieldRequirement, + int Element, + int Quality, + int SpecialMask, + uint SecondarySkill, + int SecondaryRequirement); + +/// +/// The complete 120-row AmmunitionOptions table from VTank's official +/// GameInfoDB. Selection retains bv.cs ordering and equal-quality replacement. +/// +internal static class VtankAmmunitionDatabase +{ + private const string ResourceSuffix = ".VtankAmmunitionOptions.tsv"; + private static readonly Lazy Loaded = new(Load); + + public static IReadOnlyList Options => Loaded.Value; + + public static int LauncherType(uint ammoType) => ammoType switch + { + 0x001u or 0x008u or 0x040u => 5, + 0x002u or 0x010u or 0x080u => 6, + 0x004u or 0x020u or 0x100u => 7, + _ => 0, + }; + + public static VtankAmmunitionOption? Select( + int launcherType, + MonsterDamageType damage, + VtankPrismaticAmmoPolicy prismatic, + int enabledSpecialMask, + ICharacterInfo character, + Func isAvailable) + { + ArgumentNullException.ThrowIfNull(character); + ArgumentNullException.ThrowIfNull(isAvailable); + int desiredElement = Element(damage); + if (launcherType == 0 || desiredElement < 0) + return null; + + VtankAmmunitionOption? best = null; + int bestQuality = int.MinValue; + foreach (VtankAmmunitionOption option in Loaded.Value) + { + if (option.LauncherType != launcherType) + continue; + int quality = option.Quality; + if (prismatic == VtankPrismaticAmmoPolicy.ForcePrismatic + && option.Element != 100) + { + quality -= 1000; + } + if (option.Element != desiredElement) + { + if (option.Element != 100) + continue; + if (prismatic == VtankPrismaticAmmoPolicy.NoPrismatic) + quality -= 1000; + } + if (quality < bestQuality + || !MeetsRequirements(option, character) + || (option.SpecialMask != 0 + && (option.SpecialMask & enabledSpecialMask) == 0) + || !isAvailable(option.Name)) + { + continue; + } + bestQuality = quality; + best = option; + } + return best; + } + + private static bool MeetsRequirements( + in VtankAmmunitionOption option, + ICharacterInfo character) + { + if (option.WieldRequirement > 0) + { + if (!character.TryGetSkill(47u, out PluginSkillInfo missile) + || missile.Training == PluginSkillTraining.Untrained + || missile.Base < option.WieldRequirement) + { + return false; + } + } + if (option.SecondarySkill == 0u || option.SecondaryRequirement == 0) + return true; + return character.TryGetSkill( + option.SecondarySkill, + out PluginSkillInfo secondary) + && secondary.Training != PluginSkillTraining.Untrained + && secondary.Current >= option.SecondaryRequirement; + } + + private static int Element(MonsterDamageType damage) => damage switch + { + MonsterDamageType.Pierce => 0, + MonsterDamageType.Bludgeon => 1, + MonsterDamageType.Slash => 2, + MonsterDamageType.Acid => 3, + MonsterDamageType.Electric => 4, + MonsterDamageType.Cold => 5, + MonsterDamageType.Fire => 6, + // ForcePrismatic still needs a concrete comparison element for the + // official fallback scoring; Pierce is VTank's seed value. + MonsterDamageType.Prismatic => 0, + _ => -1, + }; + + private static VtankAmmunitionOption[] Load() + { + Assembly assembly = typeof(VtankAmmunitionDatabase).Assembly; + string resource = assembly.GetManifestResourceNames().Single( + static name => name.EndsWith(ResourceSuffix, StringComparison.Ordinal)); + using Stream stream = assembly.GetManifestResourceStream(resource) + ?? throw new InvalidOperationException( + "The embedded VTank AmmunitionOptions table is missing."); + using var reader = new StreamReader(stream); + var all = new List(120); + while (reader.ReadLine() is { } line) + { + if (line.Length == 0 || line[0] == '#') + continue; + string[] fields = line.Split('\t'); + if (fields.Length != 8) + throw new InvalidDataException("Malformed VTank ammunition row."); + all.Add(new VtankAmmunitionOption( + fields[0], + Parse(fields[1]), + Parse(fields[2]), + Parse(fields[3]), + Parse(fields[4]), + Parse(fields[5]), + (uint)Parse(fields[6]), + Parse(fields[7]))); + } + if (all.Count != 120) + { + throw new InvalidDataException( + $"Expected 120 official VTank ammunition rows, found {all.Count}."); + } + return [.. all]; + } + + private static int Parse(string value) => + int.Parse(value, CultureInfo.InvariantCulture); +} diff --git a/src/AcDream.Plugins.MossTank/VtankAmmunitionOptions.tsv b/src/AcDream.Plugins.MossTank/VtankAmmunitionOptions.tsv new file mode 100644 index 00000000..e5d34730 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankAmmunitionOptions.tsv @@ -0,0 +1,121 @@ +# AmmoName LauncherType WieldReq Element Quality Special WieldReq2Skill WieldReq2Value +Barbed Quarrel 6 0 0 4 0 0 0 +Greater Barbed Quarrel 6 0 0 9 0 0 0 +Deadly Barbed Quarrel 6 230 0 22 0 0 0 +Blunt Quarrel 6 0 1 5 0 0 0 +Greater Blunt Quarrel 6 0 1 10 0 0 0 +Deadly Blunt Quarrel 6 230 1 20 0 0 0 +Armor Piercing Quarrel 6 0 0 5 0 0 0 +Greater Armor Piercing Quarrel 6 0 0 10 0 0 0 +Deadly Armor Piercing Quarrel 6 230 0 23 0 0 0 +Frog Crotch Quarrel 6 0 2 5 0 0 0 +Greater Frog Crotch Quarrel 6 0 2 10 0 0 0 +Deadly Frog Crotch Quarrel 6 230 2 23 0 0 0 +Fire Quarrel 6 0 6 5 0 0 0 +Greater Fire Quarrel 6 0 6 10 0 0 0 +Deadly Fire Quarrel 6 230 6 20 0 0 0 +Lightning Quarrel 6 0 4 5 0 0 0 +Greater Lightning Quarrel 6 0 4 10 0 0 0 +Deadly Lightning Quarrel 6 230 4 20 0 0 0 +Acid Quarrel 6 0 3 5 0 0 0 +Greater Acid Quarrel 6 0 3 10 0 0 0 +Deadly Acid Quarrel 6 230 3 20 0 0 0 +Frost Quarrel 6 0 5 5 0 0 0 +Greater Frost Quarrel 6 0 5 10 0 0 0 +Deadly Frost Quarrel 6 230 5 20 0 0 0 +Barbed Atlatl Dart 7 0 0 4 0 0 0 +Greater Barbed Atlatl Dart 7 0 0 9 0 0 0 +Deadly Barbed Atlatl Dart 7 230 0 22 0 0 0 +Blunt Atlatl Dart 7 0 1 5 0 0 0 +Greater Blunt Atlatl Dart 7 0 1 10 0 0 0 +Deadly Blunt Atlatl Dart 7 230 1 20 0 0 0 +Armor Piercing Atlatl Dart 7 0 0 5 0 0 0 +Greater Armor Piercing Atlatl Dart 7 0 0 10 0 0 0 +Deadly Armor Piercing Atlatl Dart 7 230 0 23 0 0 0 +Frog Crotch Atlatl Dart 7 0 2 5 0 0 0 +Greater Frog Crotch Atlatl Dart 7 0 2 10 0 0 0 +Deadly Frog Crotch Atlatl Dart 7 230 2 23 0 0 0 +Fire Atlatl Dart 7 0 6 5 0 0 0 +Greater Fire Atlatl Dart 7 0 6 10 0 0 0 +Deadly Fire Atlatl Dart 7 230 6 20 0 0 0 +Lightning Atlatl Dart 7 0 4 5 0 0 0 +Greater Lightning Atlatl Dart 7 0 4 10 0 0 0 +Deadly Lightning Atlatl Dart 7 230 4 20 0 0 0 +Acid Atlatl Dart 7 0 3 5 0 0 0 +Greater Acid Atlatl Dart 7 0 3 10 0 0 0 +Deadly Acid Atlatl Dart 7 230 3 20 0 0 0 +Frost Atlatl Dart 7 0 5 5 0 0 0 +Greater Frost Atlatl Dart 7 0 5 10 0 0 0 +Deadly Frost Atlatl Dart 7 230 5 20 0 0 0 +Barbed Arrow 5 0 0 4 0 0 0 +Greater Barbed Arrow 5 0 0 9 0 0 0 +Deadly Barbed Arrow 5 230 0 22 0 0 0 +Blunt Arrow 5 0 1 5 0 0 0 +Greater Blunt Arrow 5 0 1 10 0 0 0 +Deadly Blunt Arrow 5 230 1 20 0 0 0 +Armor Piercing Arrow 5 0 0 5 0 0 0 +Greater Armor Piercing Arrow 5 0 0 10 0 0 0 +Deadly Armor Piercing Arrow 5 230 0 23 0 0 0 +Frog Crotch Arrow 5 0 2 5 0 0 0 +Greater Frog Crotch Arrow 5 0 2 10 0 0 0 +Deadly Frog Crotch Arrow 5 230 2 23 0 0 0 +Fire Arrow 5 0 6 5 0 0 0 +Greater Fire Arrow 5 0 6 10 0 0 0 +Deadly Fire Arrow 5 230 6 20 0 0 0 +Lightning Arrow 5 0 4 5 0 0 0 +Greater Lightning Arrow 5 0 4 10 0 0 0 +Deadly Lightning Arrow 5 230 4 20 0 0 0 +Acid Arrow 5 0 3 5 0 0 0 +Greater Acid Arrow 5 0 3 10 0 0 0 +Deadly Acid Arrow 5 230 3 20 0 0 0 +Frost Arrow 5 0 5 5 0 0 0 +Greater Frost Arrow 5 0 5 10 0 0 0 +Deadly Frost Arrow 5 230 5 20 0 0 0 +Deadly Arrow 5 230 0 20 0 0 0 +Deadly Quarrel 6 230 0 20 0 0 0 +Deadly Atlatl Dart 7 230 0 20 0 0 0 +Deadly Broadhead Arrow 5 230 2 20 0 0 0 +Deadly Broadhead Quarrel 6 230 2 20 0 0 0 +Deadly Broadhead Atlatl Dart 7 230 2 20 0 0 0 +Greater Broadhead Atlatl Dart 7 0 2 8 0 0 0 +Greater Broadhead Arrow 5 0 2 8 0 0 0 +Greater Broadhead Quarrel 6 0 2 8 0 0 0 +Arrow 5 0 0 3 0 0 0 +Atlatl Dart 7 0 0 3 0 0 0 +Quarrel 6 0 0 3 0 0 0 +Broadhead Arrow 5 0 2 3 0 0 0 +Broadhead Quarrel 6 0 2 3 0 0 0 +Broadhead Atlatl Dart 7 0 2 3 0 0 0 +Raider Lightning Bolt 6 270 4 30 1 0 0 +Raider Lightning Atlatl Dart 7 270 4 30 1 0 0 +Raider Lightning Arrow 5 270 4 30 1 0 0 +Spectral Chill Arrow 5 270 5 30 2 0 0 +Spectral Chill Bolt 6 270 5 30 2 0 0 +Spectral Chill Atlatl Dart 7 270 5 30 2 0 0 +Olthoi Acid Arrow 5 270 3 30 2 0 0 +Olthoi Acid Bolt 6 270 3 30 2 0 0 +Olthoi Acid Atlatl Dart 7 270 3 30 2 0 0 +Greater Deadly Blunt Arrow 5 270 1 30 0 0 0 +Greater Deadly Blunt Quarrel 6 270 1 30 0 0 0 +Greater Deadly Blunt Atlatl Dart 7 270 1 30 0 0 0 +Gear Blade Slashing Arrow 5 270 2 30 2 0 0 +Gear Blade Slashing Bolt 6 270 2 30 2 0 0 +Gear Blade Slashing Atlatl Dart 7 270 2 30 2 0 0 +Burning Sands Atlatl Dart 7 270 6 30 2 0 0 +Burning Sands Bolt 6 270 6 30 2 0 0 +Burning Sands Arrow 5 270 6 30 2 0 0 +Greater Deadly Armor Piercing Atlatl Dart 7 270 0 33 0 0 0 +Greater Deadly Armor Piercing Arrow 5 270 0 33 0 0 0 +Greater Deadly Armor Piercing Quarrel 6 270 0 33 0 0 0 +Greater Deadly Frog Crotch Atlatl Dart 7 270 2 33 0 0 0 +Greater Deadly Frog Crotch Quarrel 6 270 2 33 0 0 0 +Greater Deadly Frog Crotch Arrow 5 270 2 33 0 0 0 +Deadly Prismatic Atlatl Dart 7 300 100 31 0 37 375 +Deadly Prismatic Quarrel 6 300 100 31 0 37 375 +Deadly Prismatic Arrow 5 300 100 31 0 37 375 +Greater Prismatic Atlatl Dart 7 290 100 26 0 37 350 +Greater Prismatic Quarrel 6 290 100 26 0 37 350 +Greater Prismatic Arrow 5 290 100 26 0 37 350 +Prismatic Atlatl Dart 7 250 100 21 0 37 250 +Prismatic Quarrel 6 250 100 21 0 37 250 +Prismatic Arrow 5 250 100 21 0 37 250 diff --git a/src/AcDream.Plugins.MossTank/VtankCraftDatabase.cs b/src/AcDream.Plugins.MossTank/VtankCraftDatabase.cs new file mode 100644 index 00000000..fe4f70c5 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankCraftDatabase.cs @@ -0,0 +1,81 @@ +using System.Globalization; +using System.Reflection; + +namespace AcDream.Plugins.MossTank; + +internal readonly record struct VtankCraftRecipe( + string FirstItem, + string SecondItem, + string ResultItem, + int ResultCount, + uint RequiredSkill, + int Difficulty, + int Id); + +/// +/// The complete 757-row CraftInteractions table shipped by VTank's official +/// GameInfoDB. Order is significant: VTank walks matching recipes in database +/// order and recursively tries their ingredients. +/// +internal static class VtankCraftDatabase +{ + private const string ResourceSuffix = ".VtankCraftRecipes.tsv"; + private static readonly Lazy Loaded = new(Load); + + public static IReadOnlyList Recipes => Loaded.Value.All; + + public static IReadOnlyList ForResult(string resultName) + { + if (string.IsNullOrWhiteSpace(resultName)) + return Array.Empty(); + return Loaded.Value.ByResult.TryGetValue( + resultName.Trim(), + out VtankCraftRecipe[]? recipes) + ? recipes + : Array.Empty(); + } + + private static Catalog Load() + { + Assembly assembly = typeof(VtankCraftDatabase).Assembly; + string resource = assembly.GetManifestResourceNames().Single( + static name => name.EndsWith(ResourceSuffix, StringComparison.Ordinal)); + using Stream stream = assembly.GetManifestResourceStream(resource) + ?? throw new InvalidOperationException( + "The embedded VTank CraftInteractions table is missing."); + using var reader = new StreamReader(stream); + var all = new List(757); + while (reader.ReadLine() is { } line) + { + if (line.Length == 0 || line[0] == '#') + continue; + string[] fields = line.Split('\t'); + if (fields.Length != 7) + throw new InvalidDataException("Malformed VTank craft row."); + all.Add(new VtankCraftRecipe( + fields[0], + fields[1], + fields[2], + int.Parse(fields[3], CultureInfo.InvariantCulture), + uint.Parse(fields[4], CultureInfo.InvariantCulture), + int.Parse(fields[5], CultureInfo.InvariantCulture), + int.Parse(fields[6], CultureInfo.InvariantCulture))); + } + if (all.Count != 757) + { + throw new InvalidDataException( + $"Expected 757 official VTank craft rows, found {all.Count}."); + } + Dictionary byResult = all + .GroupBy(static recipe => recipe.ResultItem, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + static group => group.Key, + static group => group.OrderBy(recipe => recipe.Id).ToArray(), + StringComparer.OrdinalIgnoreCase); + return new Catalog(all.ToArray(), byResult); + } + + private sealed record Catalog( + VtankCraftRecipe[] All, + Dictionary ByResult); +} diff --git a/src/AcDream.Plugins.MossTank/VtankCraftRecipes.tsv b/src/AcDream.Plugins.MossTank/VtankCraftRecipes.tsv new file mode 100644 index 00000000..0e027739 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankCraftRecipes.tsv @@ -0,0 +1,758 @@ +# Official VTank GameInfoDB CraftInteractions: item1item2resultcountskilldifficultyid +Wrapped Bundle of Barbed Arrowheads Wrapped Bundle of Arrowshafts Barbed Arrow 250 37 55 1 +Wrapped Bundle of Greater Barbed Arrowheads Wrapped Bundle of Arrowshafts Greater Barbed Arrow 250 37 209 2 +Wrapped Bundle of Deadly Barbed Arrowheads Wrapped Bundle of Arrowshafts Deadly Barbed Arrow 250 37 220 3 +Wrapped Bundle of Barbed Arrowheads Wrapped Bundle of Quarrelshafts Barbed Quarrel 250 37 55 4 +Wrapped Bundle of Greater Barbed Arrowheads Wrapped Bundle of Quarrelshafts Greater Barbed Quarrel 250 37 209 5 +Wrapped Bundle of Deadly Barbed Arrowheads Wrapped Bundle of Quarrelshafts Deadly Barbed Quarrel 250 37 220 6 +Wrapped Bundle of Barbed Arrowheads Wrapped Bundle of Atlatl Dartshafts Barbed Atlatl Dart 250 37 55 7 +Wrapped Bundle of Greater Barbed Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Barbed Atlatl Dart 250 37 209 8 +Wrapped Bundle of Deadly Barbed Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Barbed Atlatl Dart 250 37 220 9 +Wrapped Bundle of Blunt Arrowheads Wrapped Bundle of Arrowshafts Blunt Arrow 250 37 0 10 +Wrapped Bundle of Greater Blunt Arrowheads Wrapped Bundle of Arrowshafts Greater Blunt Arrow 250 37 0 11 +Wrapped Bundle of Deadly Blunt Arrowheads Wrapped Bundle of Arrowshafts Deadly Blunt Arrow 250 37 198 12 +Wrapped Bundle of Blunt Arrowheads Wrapped Bundle of Quarrelshafts Blunt Quarrel 250 37 0 13 +Wrapped Bundle of Greater Blunt Arrowheads Wrapped Bundle of Quarrelshafts Greater Blunt Quarrel 250 37 0 14 +Wrapped Bundle of Deadly Blunt Arrowheads Wrapped Bundle of Quarrelshafts Deadly Blunt Quarrel 250 37 198 15 +Wrapped Bundle of Blunt Arrowheads Wrapped Bundle of Atlatl Dartshafts Blunt Atlatl Dart 250 37 0 16 +Wrapped Bundle of Greater Blunt Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Blunt Atlatl Dart 250 37 0 17 +Wrapped Bundle of Deadly Blunt Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Blunt Atlatl Dart 250 37 198 18 +Wrapped Bundle of Armor Piercing Arrowheads Wrapped Bundle of Arrowshafts Armor Piercing Arrow 250 37 0 19 +Wrapped Bundle of Greater Armor Piercing Arrowheads Wrapped Bundle of Arrowshafts Greater Armor Piercing Arrow 250 37 0 20 +Wrapped Bundle of Deadly Armor Piercing Arrowheads Wrapped Bundle of Arrowshafts Deadly Armor Piercing Arrow 250 37 220 21 +Wrapped Bundle of Armor Piercing Arrowheads Wrapped Bundle of Quarrelshafts Armor Piercing Quarrel 250 37 0 22 +Wrapped Bundle of Greater Armor Piercing Arrowheads Wrapped Bundle of Quarrelshafts Greater Armor Piercing Quarrel 250 37 0 23 +Wrapped Bundle of Deadly Armor Piercing Arrowheads Wrapped Bundle of Quarrelshafts Deadly Armor Piercing Quarrel 250 37 220 24 +Wrapped Bundle of Armor Piercing Arrowheads Wrapped Bundle of Atlatl Dartshafts Armor Piercing Atlatl Dart 250 37 0 25 +Wrapped Bundle of Greater Armor Piercing Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Armor Piercing Atlatl Dart 250 37 0 26 +Wrapped Bundle of Deadly Armor Piercing Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Armor Piercing Atlatl Dart 250 37 220 27 +Wrapped Bundle of Frog Crotch Arrowheads Wrapped Bundle of Arrowshafts Frog Crotch Arrow 250 37 0 28 +Wrapped Bundle of Greater Frog Crotch Arrowheads Wrapped Bundle of Arrowshafts Greater Frog Crotch Arrow 250 37 0 29 +Wrapped Bundle of Deadly Frog Crotch Arrowheads Wrapped Bundle of Arrowshafts Deadly Frog Crotch Arrow 250 37 220 30 +Wrapped Bundle of Frog Crotch Arrowheads Wrapped Bundle of Quarrelshafts Frog Crotch Quarrel 250 37 0 31 +Wrapped Bundle of Greater Frog Crotch Arrowheads Wrapped Bundle of Quarrelshafts Greater Frog Crotch Quarrel 250 37 0 32 +Wrapped Bundle of Deadly Frog Crotch Arrowheads Wrapped Bundle of Quarrelshafts Deadly Frog Crotch Quarrel 250 37 220 33 +Wrapped Bundle of Frog Crotch Arrowheads Wrapped Bundle of Atlatl Dartshafts Frog Crotch Atlatl Dart 250 37 0 34 +Wrapped Bundle of Greater Frog Crotch Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Frog Crotch Atlatl Dart 250 37 0 35 +Wrapped Bundle of Deadly Frog Crotch Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Frog Crotch Atlatl Dart 250 37 220 36 +Wrapped Bundle of Fire Arrowheads Wrapped Bundle of Arrowshafts Fire Arrow 250 37 0 37 +Wrapped Bundle of Greater Fire Arrowheads Wrapped Bundle of Arrowshafts Greater Fire Arrow 250 37 0 38 +Wrapped Bundle of Deadly Fire Arrowheads Wrapped Bundle of Arrowshafts Deadly Fire Arrow 250 37 275 39 +Wrapped Bundle of Fire Arrowheads Wrapped Bundle of Quarrelshafts Fire Quarrel 250 37 0 40 +Wrapped Bundle of Greater Fire Arrowheads Wrapped Bundle of Quarrelshafts Greater Fire Quarrel 250 37 0 41 +Wrapped Bundle of Deadly Fire Arrowheads Wrapped Bundle of Quarrelshafts Deadly Fire Quarrel 250 37 275 42 +Wrapped Bundle of Fire Arrowheads Wrapped Bundle of Atlatl Dartshafts Fire Atlatl Dart 250 37 0 43 +Wrapped Bundle of Greater Fire Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Fire Atlatl Dart 250 37 0 44 +Wrapped Bundle of Deadly Fire Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Fire Atlatl Dart 250 37 275 45 +Wrapped Bundle of Lightning Arrowheads Wrapped Bundle of Arrowshafts Lightning Arrow 250 37 0 46 +Wrapped Bundle of Greater Lightning Arrowheads Wrapped Bundle of Arrowshafts Greater Lightning Arrow 250 37 0 47 +Wrapped Bundle of Deadly Lightning Arrowheads Wrapped Bundle of Arrowshafts Deadly Lightning Arrow 250 37 275 48 +Wrapped Bundle of Lightning Arrowheads Wrapped Bundle of Quarrelshafts Lightning Quarrel 250 37 0 49 +Wrapped Bundle of Greater Lightning Arrowheads Wrapped Bundle of Quarrelshafts Greater Lightning Quarrel 250 37 0 50 +Wrapped Bundle of Deadly Lightning Arrowheads Wrapped Bundle of Quarrelshafts Deadly Lightning Quarrel 250 37 275 51 +Wrapped Bundle of Lightning Arrowheads Wrapped Bundle of Atlatl Dartshafts Lightning Atlatl Dart 250 37 0 52 +Wrapped Bundle of Greater Lightning Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Lightning Atlatl Dart 250 37 0 53 +Wrapped Bundle of Deadly Lightning Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Lightning Atlatl Dart 250 37 275 54 +Wrapped Bundle of Acid Arrowheads Wrapped Bundle of Arrowshafts Acid Arrow 250 37 0 55 +Wrapped Bundle of Greater Acid Arrowheads Wrapped Bundle of Arrowshafts Greater Acid Arrow 250 37 0 56 +Wrapped Bundle of Deadly Acid Arrowheads Wrapped Bundle of Arrowshafts Deadly Acid Arrow 250 37 275 57 +Wrapped Bundle of Acid Arrowheads Wrapped Bundle of Quarrelshafts Acid Quarrel 250 37 0 58 +Wrapped Bundle of Greater Acid Arrowheads Wrapped Bundle of Quarrelshafts Greater Acid Quarrel 250 37 0 59 +Wrapped Bundle of Deadly Acid Arrowheads Wrapped Bundle of Quarrelshafts Deadly Acid Quarrel 250 37 275 60 +Wrapped Bundle of Acid Arrowheads Wrapped Bundle of Atlatl Dartshafts Acid Atlatl Dart 250 37 0 61 +Wrapped Bundle of Greater Acid Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Acid Atlatl Dart 250 37 0 62 +Wrapped Bundle of Deadly Acid Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Acid Atlatl Dart 250 37 275 63 +Wrapped Bundle of Frost Arrowheads Wrapped Bundle of Arrowshafts Frost Arrow 250 37 0 64 +Wrapped Bundle of Greater Frost Arrowheads Wrapped Bundle of Arrowshafts Greater Frost Arrow 250 37 0 65 +Wrapped Bundle of Deadly Frost Arrowheads Wrapped Bundle of Arrowshafts Deadly Frost Arrow 250 37 275 66 +Wrapped Bundle of Frost Arrowheads Wrapped Bundle of Quarrelshafts Frost Quarrel 250 37 0 67 +Wrapped Bundle of Greater Frost Arrowheads Wrapped Bundle of Quarrelshafts Greater Frost Quarrel 250 37 0 68 +Wrapped Bundle of Deadly Frost Arrowheads Wrapped Bundle of Quarrelshafts Deadly Frost Quarrel 250 37 275 69 +Wrapped Bundle of Frost Arrowheads Wrapped Bundle of Atlatl Dartshafts Frost Atlatl Dart 250 37 0 70 +Wrapped Bundle of Greater Frost Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Frost Atlatl Dart 250 37 0 71 +Wrapped Bundle of Deadly Frost Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Frost Atlatl Dart 250 37 275 72 +Bundle of Barbed Arrowheads Bundle of Arrowshafts Barbed Arrow 10 37 0 73 +Bundle of Greater Barbed Arrowheads Bundle of Arrowshafts Greater Barbed Arrow 10 37 0 74 +Bundle of Deadly Barbed Arrowheads Bundle of Arrowshafts Deadly Barbed Arrow 10 37 0 75 +Bundle of Barbed Arrowheads Bundle of Quarrelshafts Barbed Quarrel 10 37 0 76 +Bundle of Greater Barbed Arrowheads Bundle of Quarrelshafts Greater Barbed Quarrel 10 37 0 77 +Bundle of Deadly Barbed Arrowheads Bundle of Quarrelshafts Deadly Barbed Quarrel 10 37 0 78 +Bundle of Barbed Arrowheads Bundle of Atlatl Dart shafts Barbed Atlatl Dart 10 37 0 79 +Bundle of Greater Barbed Arrowheads Bundle of Atlatl Dart shafts Greater Barbed Atlatl Dart 10 37 0 80 +Bundle of Deadly Barbed Arrowheads Bundle of Atlatl Dart shafts Deadly Barbed Atlatl Dart 10 37 0 81 +Bundle of Blunt Arrowheads Bundle of Arrowshafts Blunt Arrow 10 37 0 82 +Bundle of Greater Blunt Arrowheads Bundle of Arrowshafts Greater Blunt Arrow 10 37 0 83 +Bundle of Deadly Blunt Arrowheads Bundle of Arrowshafts Deadly Blunt Arrow 10 37 0 84 +Bundle of Blunt Arrowheads Bundle of Quarrelshafts Blunt Quarrel 10 37 0 85 +Bundle of Greater Blunt Arrowheads Bundle of Quarrelshafts Greater Blunt Quarrel 10 37 0 86 +Bundle of Deadly Blunt Arrowheads Bundle of Quarrelshafts Deadly Blunt Quarrel 10 37 0 87 +Bundle of Blunt Arrowheads Bundle of Atlatl Dart shafts Blunt Atlatl Dart 10 37 0 88 +Bundle of Greater Blunt Arrowheads Bundle of Atlatl Dart shafts Greater Blunt Atlatl Dart 10 37 0 89 +Bundle of Deadly Blunt Arrowheads Bundle of Atlatl Dart shafts Deadly Blunt Atlatl Dart 10 37 0 90 +Bundle of Armor Piercing Arrowheads Bundle of Arrowshafts Armor Piercing Arrow 10 37 0 91 +Bundle of Greater Armor Piercing Arrowheads Bundle of Arrowshafts Greater Armor Piercing Arrow 10 37 0 92 +Bundle of Deadly Armor Piercing Arrowheads Bundle of Arrowshafts Deadly Armor Piercing Arrow 10 37 0 93 +Bundle of Armor Piercing Arrowheads Bundle of Quarrelshafts Armor Piercing Quarrel 10 37 0 94 +Bundle of Greater Armor Piercing Arrowheads Bundle of Quarrelshafts Greater Armor Piercing Quarrel 10 37 0 95 +Bundle of Deadly Armor Piercing Arrowheads Bundle of Quarrelshafts Deadly Armor Piercing Quarrel 10 37 0 96 +Bundle of Armor Piercing Arrowheads Bundle of Atlatl Dart shafts Armor Piercing Atlatl Dart 10 37 0 97 +Bundle of Greater Armor Piercing Arrowheads Bundle of Atlatl Dart shafts Greater Armor Piercing Atlatl Dart 10 37 0 98 +Bundle of Deadly Armor Piercing Arrowheads Bundle of Atlatl Dart shafts Deadly Armor Piercing Atlatl Dart 10 37 0 99 +Bundle of Frog Crotch Arrowheads Bundle of Arrowshafts Frog Crotch Arrow 10 37 0 100 +Bundle of Greater Frog Crotch Arrowheads Bundle of Arrowshafts Greater Frog Crotch Arrow 10 37 0 101 +Bundle of Deadly Frog Crotch Arrowheads Bundle of Arrowshafts Deadly Frog Crotch Arrow 10 37 0 102 +Bundle of Frog Crotch Arrowheads Bundle of Quarrelshafts Frog Crotch Quarrel 10 37 0 103 +Bundle of Greater Frog Crotch Arrowheads Bundle of Quarrelshafts Greater Frog Crotch Quarrel 10 37 0 104 +Bundle of Deadly Frog Crotch Arrowheads Bundle of Quarrelshafts Deadly Frog Crotch Quarrel 10 37 0 105 +Bundle of Frog Crotch Arrowheads Bundle of Atlatl Dart shafts Frog Crotch Atlatl Dart 10 37 0 106 +Bundle of Greater Frog Crotch Arrowheads Bundle of Atlatl Dart shafts Greater Frog Crotch Atlatl Dart 10 37 0 107 +Bundle of Deadly Frog Crotch Arrowheads Bundle of Atlatl Dart shafts Deadly Frog Crotch Atlatl Dart 10 37 0 108 +Bundle of Fire Arrowheads Bundle of Arrowshafts Fire Arrow 10 37 0 109 +Bundle of Greater Fire Arrowheads Bundle of Arrowshafts Greater Fire Arrow 10 37 0 110 +Bundle of Deadly Fire Arrowheads Bundle of Arrowshafts Deadly Fire Arrow 10 37 0 111 +Bundle of Fire Arrowheads Bundle of Quarrelshafts Fire Quarrel 10 37 0 112 +Bundle of Greater Fire Arrowheads Bundle of Quarrelshafts Greater Fire Quarrel 10 37 0 113 +Bundle of Deadly Fire Arrowheads Bundle of Quarrelshafts Deadly Fire Quarrel 10 37 0 114 +Bundle of Fire Arrowheads Bundle of Atlatl Dart shafts Fire Atlatl Dart 10 37 0 115 +Bundle of Greater Fire Arrowheads Bundle of Atlatl Dart shafts Greater Fire Atlatl Dart 10 37 0 116 +Bundle of Deadly Fire Arrowheads Bundle of Atlatl Dart shafts Deadly Fire Atlatl Dart 10 37 0 117 +Bundle of Lightning Arrowheads Bundle of Arrowshafts Lightning Arrow 10 37 0 118 +Bundle of Greater Lightning Arrowheads Bundle of Arrowshafts Greater Lightning Arrow 10 37 0 119 +Bundle of Deadly Lightning Arrowheads Bundle of Arrowshafts Deadly Lightning Arrow 10 37 0 120 +Bundle of Lightning Arrowheads Bundle of Quarrelshafts Lightning Quarrel 10 37 0 121 +Bundle of Greater Lightning Arrowheads Bundle of Quarrelshafts Greater Lightning Quarrel 10 37 0 122 +Bundle of Deadly Lightning Arrowheads Bundle of Quarrelshafts Deadly Lightning Quarrel 10 37 0 123 +Bundle of Lightning Arrowheads Bundle of Atlatl Dart shafts Lightning Atlatl Dart 10 37 0 124 +Bundle of Greater Lightning Arrowheads Bundle of Atlatl Dart shafts Greater Lightning Atlatl Dart 10 37 0 125 +Bundle of Deadly Lightning Arrowheads Bundle of Atlatl Dart shafts Deadly Lightning Atlatl Dart 10 37 0 126 +Bundle of Acid Arrowheads Bundle of Arrowshafts Acid Arrow 10 37 0 127 +Bundle of Greater Acid Arrowheads Bundle of Arrowshafts Greater Acid Arrow 10 37 0 128 +Bundle of Deadly Acid Arrowheads Bundle of Arrowshafts Deadly Acid Arrow 10 37 0 129 +Bundle of Acid Arrowheads Bundle of Quarrelshafts Acid Quarrel 10 37 0 130 +Bundle of Greater Acid Arrowheads Bundle of Quarrelshafts Greater Acid Quarrel 10 37 0 131 +Bundle of Deadly Acid Arrowheads Bundle of Quarrelshafts Deadly Acid Quarrel 10 37 0 132 +Bundle of Acid Arrowheads Bundle of Atlatl Dart shafts Acid Atlatl Dart 10 37 0 133 +Bundle of Greater Acid Arrowheads Bundle of Atlatl Dart shafts Greater Acid Atlatl Dart 10 37 0 134 +Bundle of Deadly Acid Arrowheads Bundle of Atlatl Dart shafts Deadly Acid Atlatl Dart 10 37 0 135 +Bundle of Frost Arrowheads Bundle of Arrowshafts Frost Arrow 10 37 0 136 +Bundle of Greater Frost Arrowheads Bundle of Arrowshafts Greater Frost Arrow 10 37 0 137 +Bundle of Deadly Frost Arrowheads Bundle of Arrowshafts Deadly Frost Arrow 10 37 0 138 +Bundle of Frost Arrowheads Bundle of Quarrelshafts Frost Quarrel 10 37 0 139 +Bundle of Greater Frost Arrowheads Bundle of Quarrelshafts Greater Frost Quarrel 10 37 0 140 +Bundle of Deadly Frost Arrowheads Bundle of Quarrelshafts Deadly Frost Quarrel 10 37 0 141 +Bundle of Frost Arrowheads Bundle of Atlatl Dart shafts Frost Atlatl Dart 10 37 0 142 +Bundle of Greater Frost Arrowheads Bundle of Atlatl Dart shafts Greater Frost Atlatl Dart 10 37 0 143 +Bundle of Deadly Frost Arrowheads Bundle of Atlatl Dart shafts Deadly Frost Atlatl Dart 10 37 0 144 +Cooking Pot Simple Dried Rations Simple Field Rations 25 39 0 145 +Cooking Pot Elaborate Dried Rations Elaborate Field Rations 25 39 0 146 +Cooking Pot Simple Dried Health Rations Simple Field Health Rations 25 39 0 147 +Cooking Pot Elaborate Dried Health Rations Elaborate Field Health Rations 25 39 0 148 +Cooking Pot Simple Dried Mana Rations Simple Field Mana Rations 25 39 0 149 +Cooking Pot Elaborate Dried Mana Rations Elaborate Field Mana Rations 25 39 0 150 +Mortar and Pestle Hot Pepper Hot Sauce 1 39 0 151 +Hot Sauce Simple Dried Rations Simple Dried Health Rations 1 39 0 152 +Hot Sauce Elaborate Dried Rations Elaborate Dried Health Rations 1 39 0 153 +Cinnamon Simple Dried Rations Simple Dried Mana Rations 1 39 0 154 +Cinnamon Elaborate Dried Rations Elaborate Dried Mana Rations 1 39 0 155 +Treated Mandrake Treated Hyssop Combined Hyssop and Mandrake 1 21 0 156 +Soft Bandages Combined Hyssop and Mandrake Plentiful Healing Kit 1 21 0 157 +Health Infusion Potion of Healing Trade Health Elixir 0 38 0 158 +Concentrated Bloodhunter Oil Wrapped Bundle of Greater Frog Crotch Arrowheads Wrapped Bundle of Deadly Frog Crotch Arrowheads 0 37 0 159 +Concentrated Bloodhunter Oil Wrapped Bundle of Greater Armor Piercing Arrowheads Wrapped Bundle of Deadly Armor Piercing Arrowheads 0 37 0 160 +Concentrated Bloodhunter Oil Wrapped Bundle of Greater Blunt Arrowheads Wrapped Bundle of Deadly Blunt Arrowheads 0 37 0 161 +Concentrated Bloodhunter Oil Wrapped Bundle of Greater Fire Arrowheads Wrapped Bundle of Deadly Fire Arrowheads 0 37 0 162 +Concentrated Bloodhunter Oil Wrapped Bundle of Greater Frost Arrowheads Wrapped Bundle of Deadly Frost Arrowheads 0 37 0 163 +Concentrated Bloodhunter Oil Wrapped Bundle of Greater Acid Arrowheads Wrapped Bundle of Deadly Acid Arrowheads 0 37 0 164 +Concentrated Bloodhunter Oil Wrapped Bundle of Greater Lightning Arrowheads Wrapped Bundle of Deadly Lightning Arrowheads 0 37 0 165 +Concentrated Bloodseeker Oil Wrapped Bundle of Frog Crotch Arrowheads Wrapped Bundle of Greater Frog Crotch Arrowheads 0 37 0 166 +Concentrated Bloodseeker Oil Wrapped Bundle of Armor Piercing Arrowheads Wrapped Bundle of Greater Armor Piercing Arrowheads 0 37 0 167 +Concentrated Bloodseeker Oil Wrapped Bundle of Blunt Arrowheads Wrapped Bundle of Greater Blunt Arrowheads 0 37 0 168 +Concentrated Bloodseeker Oil Wrapped Bundle of Fire Arrowheads Wrapped Bundle of Greater Fire Arrowheads 0 37 0 169 +Concentrated Bloodseeker Oil Wrapped Bundle of Frost Arrowheads Wrapped Bundle of Greater Frost Arrowheads 0 37 0 170 +Concentrated Bloodseeker Oil Wrapped Bundle of Acid Arrowheads Wrapped Bundle of Greater Acid Arrowheads 0 37 0 171 +Concentrated Bloodseeker Oil Wrapped Bundle of Lightning Arrowheads Wrapped Bundle of Greater Lightning Arrowheads 0 37 0 172 +Empty Stopped Keg Duke Raoul's Distillation Brew Keg of Duke Raoul's Distillation 0 0 0 173 +Empty Stopped Keg Apothecary Zongo's Stout Brew Keg of Apothecary Zongo's Stout 0 0 0 174 +Empty Stopped Keg Hunter's Stock Amber Brew Keg of Hunter's Stock Amber 0 0 0 175 +Empty Bottles Keg of Apothecary Zongo's Stout Apothecary Zongo's Stout 0 0 0 176 +Empty Bottles Keg of Duke Raoul's Distillation Duke Raoul's Distillation 0 0 0 177 +Empty Bottles Keg of Bobo's Stout Bobo's Stout 0 0 0 178 +Empty Bottles Keg of Tusker Spit Ale Tusker Spit Ale 0 0 0 179 +Empty Bottles Keg of Amber Ape Amber Ape 0 0 0 180 +Empty Bottles Keg of Hunter's Stock Amber Hunter's Stock Amber 0 0 0 181 +Neutral Balm Strong Chorizite Oil Strong Dispel Potion 0 0 0 182 +Neutral Balm Concentrated Chorizite Oil Concentrated Dispel Potion 0 0 0 183 +Neutral Balm Condensed Chorizite Oil Condensed Dispel Potion 0 0 0 184 +Empty Stopped Keg Tusker Spit Brew Keg of Tusker Spit Ale 0 0 0 185 +Empty Stopped Keg Amber Ape Brew Keg of Amber Ape 0 0 0 186 +Empty Stopped Keg Bobo's Stout Brew Keg of Bobo's Stout 0 0 0 187 +Victual Oil Healing Famous Pizza Hearty Healing Famous Pizza 0 39 0 188 +Victual Oil Healing Cake Hearty Healing Cake 0 39 0 189 +Victual Oil Healing Carrot Cake Hearty Healing Carrot Cake 0 39 0 190 +Victual Oil Healing Pizza Hearty Healing Pizza 0 39 0 191 +Victual Oil Healing Applesauce Hearty Healing Applesauce 0 39 0 192 +Victual Oil Healing Spiced Applesauce Hearty Healing Spiced Applesauce 0 39 0 193 +Victual Oil Healing Meat Pie Hearty Healing Meat Pie 0 39 0 194 +Victual Oil Healing Fish Pie Hearty Healing Fish Pie 0 39 0 195 +Victual Oil Healing Chicken Pie Hearty Healing Chicken Pie 0 39 0 196 +Victual Oil Healing Rabbit Pie Hearty Healing Rabbit Pie 0 39 0 197 +Victual Oil Healing Mushroom Pie Hearty Healing Mushroom Pie 0 39 0 198 +Victual Oil Healing Apple Pie Hearty Healing Apple Pie 0 39 0 199 +Victual Oil Healing Spiced Apple Pie Hearty Healing Spiced Apple Pie 0 39 0 200 +Victual Oil Healing Beef Stew Hearty Healing Beef Stew 0 39 0 201 +Victual Oil Healing Fish Stew Hearty Healing Fish Stew 0 39 0 202 +Victual Oil Healing Chicken Stew Hearty Healing Chicken Stew 0 39 0 203 +Victual Oil Healing Rabbit Stew Hearty Healing Rabbit Stew 0 39 0 204 +Victual Oil Healing Mushroom Stew Hearty Healing Mushroom Stew 0 39 0 205 +Victual Oil Healing Carrot Soup Hearty Healing Carrot Soup 0 39 0 206 +Victual Oil Healing Beef Noodle Hearty Healing Beef Noodle 0 39 0 207 +Victual Oil Healing Fish Noodle Hearty Healing Fish Noodle 0 39 0 208 +Victual Oil Healing Chicken Noodle Hearty Healing Chicken Noodle 0 39 0 209 +Victual Oil Healing Rabbit Noodle Hearty Healing Rabbit Noodle 0 39 0 210 +Victual Oil Healing Mushroom Noodle Hearty Healing Mushroom Noodle 0 39 0 211 +Victual Oil Healing Ice Cream Hearty Healing Icecream 0 39 0 212 +Victual Oil Healing Green Tea Ice Cream Hearty Healing Green Tea Ice Cream 0 39 0 213 +Victual Oil Healing Holtburger Hearty Healing Holtburger 0 39 0 214 +Victual Oil Healing Hot Kimchi Hearty Healing Hot Kimchi 0 39 0 215 +Victual Oil Mana Hot Kimchi Hearty Mana Hot Kimchi 0 39 0 216 +Victual Oil Mana Famous Pizza Hearty Mana Famous Pizza 0 39 0 217 +Victual Oil Mana Green Tea Ice Cream Hearty Mana Green Tea Ice Cream 0 39 0 218 +Victual Oil Mana Cake Hearty Mana Cake 0 39 0 219 +Victual Oil Mana Carrot Cake Hearty Mana Carrot Cake 0 39 0 220 +Victual Oil Mana Pizza Hearty Mana Pizza 0 39 0 221 +Victual Oil Mana Applesauce Hearty Mana Applesauce 0 39 0 222 +Victual Oil Mana Spiced Applesauce Hearty Mana Spiced Applesauce 0 39 0 223 +Victual Oil Mana Meat Pie Hearty Mana Meat Pie 0 39 0 224 +Victual Oil Mana Fish Pie Hearty Mana Fish Pie 0 39 0 225 +Victual Oil Mana Chicken Pie Hearty Mana Chicken Pie 0 39 0 226 +Victual Oil Mana Rabbit Pie Hearty Mana Rabbit Pie 0 39 0 227 +Victual Oil Mana Mushroom Pie Hearty Mana Mushroom Pie 0 39 0 228 +Victual Oil Mana Apple Pie Hearty Mana Apple Pie 0 39 0 229 +Victual Oil Mana Spiced Apple Pie Hearty Mana Spiced Apple Pie 0 39 0 230 +Victual Oil Mana Beef Stew Hearty Mana Beef Stew 0 39 0 231 +Victual Oil Mana Fish Stew Hearty Mana Fish Stew 0 39 0 232 +Victual Oil Mana Chicken Stew Hearty Mana Chicken Stew 0 39 0 233 +Victual Oil Mana Rabbit Stew Hearty Mana Rabbit Stew 0 39 0 234 +Victual Oil Mana Mushroom Stew Hearty Mana Mushroom Stew 0 39 0 235 +Victual Oil Mana Carrot Soup Hearty Mana Carrot Soup 0 39 0 236 +Victual Oil Mana Beef Noodle Hearty Mana Beef Noodle 0 39 0 237 +Victual Oil Mana Fish Noodle Hearty Mana Fish Noodle 0 39 0 238 +Victual Oil Mana Chicken Noodle Hearty Mana Chicken Noodle 0 39 0 239 +Victual Oil Mana Rabbit Noodle Hearty Mana Rabbit Noodle 0 39 0 240 +Victual Oil Mana Mushroom Noodle Hearty Mana Mushroom Noodle 0 39 0 241 +Victual Oil Mana Ice Cream Hearty Mana Icecream 0 39 0 242 +Victual Oil Mana Holtburger Hearty Mana Holtburger 0 39 0 243 +Baking Pan Olthoi Chocolate Cake Batter Chocolate Olthoi Cake 0 39 0 244 +Frying Pan Olthoi Egg Fried Olthoi Egg 0 39 0 245 +Cooking Pot Olthoi Egg Hard Boiled Olthoi Egg 0 39 0 246 +Baking Pan Olthoi Cake Batter Olthoi Cake 0 39 0 247 +Baking Pan Olthoi Carrot Cake Batter Olthoi Carrot Cake 0 39 0 248 +Olthoi Pumpkin Pie Filling Dough Olthoi Pumpkin Pie 0 39 0 249 +Olthoi Batter Bread Olthoi Toast 0 39 0 250 +Brine Olthoi Egg Pickled Olthoi Egg 0 39 0 251 +Frying Pan Marinated Olthoi Egg Vesayen Style Fried Olthoi Egg 0 39 0 252 +Hot Sauce Olthoi Egg Marinated Olthoi Egg 0 39 0 253 +Treated Stibnite and Frankincense Crucible Powdered Onyx Gem of Greater Protection 0 38 0 254 +Treated Quicksilver and Frankincense Crucible Powdered Hematite Gem of Greater Piercing Protection 0 38 0 255 +Treated Verdigris and Frankincense Crucible Powdered Turquoise Gem of Greater Bludgeon Protection 0 38 0 256 +Treated Cadmia and Frankincense Crucible Powdered Moonstone Gem of Greater Blade Protection 0 38 0 257 +Treated Brimstone and Frankincense Crucible Powdered Malachite Gem of Greater Acid Protection 0 38 0 258 +Treated Colcothar and Frankincense Crucible Powdered Quartz Gem of Greater Cold Protection 0 38 0 259 +Treated Turpeth and Frankincense Crucible Powdered Carnelian Gem of Greater Fire Protection 0 38 0 260 +Treated Cobalt and Frankincense Crucible Powdered Agate Gem of Greater Lightning Protection 0 38 0 261 +Treated Vitriol and Frankincense Crucible Powdered Bloodstone Gem of Greater Regeneration 0 38 0 262 +Treated Cinnabar and Frankincense Crucible Powdered Amber Gem of Greater Rejuvenation 0 38 0 263 +Treated Gypsum and Frankincense Crucible Powdered Lapis Lazuli Gem of Greater Mana Renewal 0 38 0 264 +Concentrated Health Infusion Concentrated Aqua Incanta Concentrated Health Oil 0 38 0 265 +Concentrated Mana Infusion Concentrated Aqua Incanta Concentrated Mana Oil 0 38 0 266 +Concentrated Victual Infusion Concentrated Aqua Incanta Concentrated Victual Oil 0 38 0 267 +Eye Dropper Concentrated Health Oil Health Oil 0 38 0 268 +Eye Dropper Concentrated Mana Oil Mana Oil 0 38 0 269 +Eye Dropper Concentrated Victual Oil Victual Oil 0 38 0 270 +Concentrated Bloodseeker Infusion Concentrated Aqua Incanta Concentrated Bloodseeker Oil 0 38 0 271 +Concentrated Bloodhunter Infusion Concentrated Aqua Incanta Concentrated Bloodhunter Oil 0 38 0 272 +Concentrated Fire Infusion Concentrated Aqua Incanta Concentrated Fire Oil 0 38 0 273 +Concentrated Frost Infusion Concentrated Aqua Incanta Concentrated Frost Oil 0 38 0 274 +Concentrated Acid Infusion Concentrated Aqua Incanta Concentrated Acid Oil 0 38 0 275 +Concentrated Lightning Infusion Concentrated Aqua Incanta Concentrated Lightning Oil 0 38 0 276 +Bloodhunter Infusion Aqua Incanta Bloodhunter Oil 0 38 0 277 +Bloodseeker Infusion Aqua Incanta Bloodseeker Oil 0 38 0 278 +Lightning Infusion Aqua Incanta Lightning Oil 0 38 0 279 +Fire Infusion Aqua Incanta Fire Oil 0 38 0 280 +Acid Infusion Aqua Incanta Acid Oil 0 38 0 281 +Frost Infusion Aqua Incanta Frost Oil 0 38 0 282 +Wrapped Bundle of Deadly Acid Arrowheads Wrapped Bundle of Arrowshafts Deadly Acid Arrow 0 37 0 283 +Wrapped Bundle of Deadly Blunt Arrowheads Wrapped Bundle of Arrowshafts Deadly Blunt Arrow 0 37 0 284 +Bundle of Deadly Arrowheads Bundle of Arrowshafts Deadly Arrow 0 37 0 285 +Wrapped Bundle of Greater Blunt Arrowheads Bundle of Arrowshafts Greater Blunt Arrow 0 37 0 286 +Wrapped Bundle of Greater Frog Crotch Arrowheads Bundle of Arrowshafts Greater Frog Crotch Arrow 0 37 0 287 +Baking Pan Dough Bread 0 39 0 288 +Carving Knife Cabbage Coleslaw 0 39 0 289 +Frying Pan Dough Flat Bread 0 39 0 290 +Frying Pan Brimstone-cap Mushroom Fried Mushroom 0 39 0 291 +Brine Egg Pickled Egg 0 39 0 292 +Brine Fish Filet Pickled Fish 0 39 0 293 +Rich Carrot Stock Cheese Carol's Carrot Soup 0 39 0 294 +Cubed Carrot Cake Milk Carrot Cake Soup 0 39 0 295 +Cooking Pot Spiced Pumpkin Pumpkin Soup 0 39 0 296 +Uncooked Rice Grapes Stuffed Grape Leaf 0 39 0 297 +Baking Pan Cheese Filled Mushroom Stuffed Mushroom 0 0 0 298 +Uncooked Rice Fish Filet Sushi 0 39 0 299 +Rat Tail Ground Rabbit Rabbit Sausage 0 39 0 300 +Rat Tail Ground Meat Sausage 0 39 0 301 +Hot Sauce Sausage Spicy Sausage 0 39 0 302 +Metal Press Apple Apple Juice 0 39 0 303 +Bitter Milk Honey Chocolate Milk 0 39 0 304 +Crushed Ice Milk Cold Milk 0 39 0 305 +Egg Spiced Milk Eggnog 0 39 0 306 +Sweetened Hot Milk Cocoa Powder Hot Chocolate 0 39 0 307 +Crushed Ice Mocha Iced Mocha 0 39 0 308 +Mocha Base Milk Mocha 0 39 0 309 +Peppermint Stick Hot Chocolate Peppermint Hot Chocolate 0 0 0 310 +Crushed Ice Rich Mocha Rich Iced Mocha 0 39 0 311 +Cinnamon Mocha Rich Mocha 0 39 0 312 +Slice of Bread Cheese Cheese Sandwich 0 39 0 313 +Slice of Bread Chicken Chicken Sandwich 0 39 0 314 +Ravener Gut Ground Meat Drudge Gut Sausage 0 39 0 315 +Slice of Bread Egg Egg Sandwich 0 39 0 316 +Slice of Bread Fish Fish Sandwich 0 39 0 317 +Frying Pan Cheese Sandwich Grilled Cheese Sandwich 0 39 0 318 +Ground Meat Bread Holtburger 0 39 0 319 +Dough Apple Apple Pie 0 39 0 320 +Heavy Grinder Apple Applesauce 0 39 0 321 +Baking Pan Cake Batter Cake 0 39 0 322 +Monougat Apple Candied Apple 0 39 0 323 +Baking Pan Carrot Cake Batter Carrot Cake 0 39 0 324 +Baking Pan Chocolate Cake Batter Chocolate Cake 0 39 0 325 +Baking Pan Chocolate Cookie Dough Chocolate Cookie 0 39 0 326 +Chocolate Liquor Ice Cream Chocolate Ice Cream 0 39 0 327 +Baking Pan Cookie Dough Cookie 0 39 0 328 +Cocoa Mixture Honey Bar Dark Chocolate 0 39 0 329 +Monougat Bar Dark Chocolate Dark Chocolate Candy Bar 0 39 0 330 +Baking Pan Fruitcake Batter Fruitcake 0 39 0 331 +Baking Pan Ginger Dough Ginger Bread 0 39 0 332 +Frozen Green Tea Honey Green Tea Ice Cream 0 39 0 333 +Frozen Cream Honey Ice Cream 0 39 0 334 +Milky Cocoa Mixture Honey Bar Milk Chocolate 0 39 0 335 +Monougat Bar Milk Chocolate Milk Chocolate Candy Bar 0 39 0 336 +Baking Pan Peppermint Chocolate Cookie Dough Peppermint Chocolate Cookie 0 39 0 337 +Baking Pan Peppermint Cookie Dough Peppermint Cookie 0 39 0 338 +Peppermint Stick Ice Cream Peppermint Ice Cream 0 39 0 339 +Monougat Peppermint Stick Peppermint Monougat Chew 0 39 0 340 +Pumpkin Pie Filling Dough Pumpkin Pie 0 39 0 341 +Dough Spiced Apple Filling Spiced Apple Pie 0 39 0 342 +Cinnamon Applesauce Spiced Applesauce 0 39 0 343 +Raw Noodles Cheese Cragstone Farms Mac and Cheese 0 39 0 344 +Raw Egg Noodles Ground Beef Cragstonanoff 0 39 0 345 +Rice Dough Chicken Chicken Dumpling 0 39 0 346 +Rice Dough Fish Fish Dumpling 0 39 0 347 +Frying Pan Chicken Piece Fried Chicken 0 39 0 348 +Frying Pan Egg Fried Egg 0 39 0 349 +Frying Pan Fish Filet Fried Fish 0 39 0 350 +Frying Pan Rabbit Piece Fried Rabbit 0 39 0 351 +Frying Pan Steak Fried Steak 0 39 0 352 +Skewer Steak Beef Kebob 0 39 0 353 +Skewer Chicken Piece Chicken Kebob 0 39 0 354 +Skewer Fish Filet Fish Kebob 0 39 0 355 +Skewer Brimstone-cap Mushroom Mushroom Kebob 0 39 0 356 +Skewer Rabbit Piece Rabbit Kebob 0 39 0 357 +Brine Cabbage Kimchi 0 39 0 358 +Hot Sauce Kimchi Hot Kimchi 0 39 0 359 +Fire Oil Hot Kimchi Flaming Kimchi 0 39 0 360 +Raw Noodles Steak Beef Noodle 0 39 0 361 +Raw Noodles Chicken Piece Chicken Noodle 0 39 0 362 +Raw Noodles Fish Filet Fish Noodle 0 39 0 363 +Raw Noodles Brimstone-cap Mushroom Mushroom Noodle 0 39 0 364 +Raw Noodles Rabbit Piece Rabbit Noodle 0 39 0 365 +Dough Chicken Piece Chicken Pie 0 39 0 366 +Dough Fish Filet Fish Pie 0 39 0 367 +Dough Steak Meat Pie 0 39 0 368 +Dough Brimstone-cap Mushroom Mushroom Pie 0 39 0 369 +Dough Rabbit Piece Rabbit Pie 0 39 0 370 +Cooking Pot Uncooked Rice Bowl of Rice 0 39 0 371 +Uncooked Rice Steak Beef Rice 0 39 0 372 +Uncooked Rice Chicken Piece Chicken Rice 0 39 0 373 +Uncooked Rice Brimstone-cap Mushroom Mushroom Rice 0 39 0 374 +Uncooked Rice Rabbit Piece Rabbit Rice 0 39 0 375 +Cooking Pot Steak Beef Stew 0 39 0 376 +Cooking Pot Chicken Piece Chicken Stew 0 39 0 377 +Cooking Pot Fish Filet Fish Stew 0 39 0 378 +Cooking Pot Brimstone-cap Mushroom Mushroom Stew 0 39 0 379 +Cooking Pot Rabbit Piece Rabbit Stew 0 39 0 380 +Batter Bread Viamont Toast 0 39 0 381 +Oregano Pizza Famous Pizza 0 39 0 382 +Dough Cheese Pizza 0 39 0 383 +Health Oil Cake Healing Cake 0 39 0 384 +Health Oil Carrot Cake Healing Carrot Cake 0 39 0 385 +Health Oil Pizza Healing Pizza 0 39 0 386 +Health Oil Famous Pizza Healing Famous Pizza 0 39 0 387 +Health Oil Applesauce Healing Applesauce 0 39 0 388 +Health Oil Spiced Applesauce Healing Spiced Applesauce 0 39 0 389 +Health Oil Meat Pie Healing Meat Pie 0 39 0 390 +Health Oil Fish Pie Healing Fish Pie 0 39 0 391 +Health Oil Chicken Pie Healing Chicken Pie 0 39 0 392 +Health Oil Rabbit Pie Healing Rabbit Pie 0 39 0 393 +Health Oil Mushroom Pie Healing Mushroom Pie 0 39 0 394 +Health Oil Apple Pie Healing Apple Pie 0 39 0 395 +Health Oil Spiced Apple Pie Healing Spiced Apple Pie 0 39 0 396 +Health Oil Beef Stew Healing Beef Stew 0 39 0 397 +Health Oil Fish Stew Healing Fish Stew 0 39 0 398 +Health Oil Chicken Stew Healing Chicken Stew 0 39 0 399 +Health Oil Rabbit Stew Healing Rabbit Stew 0 39 0 400 +Health Oil Mushroom Stew Healing Mushroom Stew 0 39 0 401 +Health Oil Carrot Soup Healing Carrot Soup 0 39 0 402 +Health Oil Beef Noodle Healing Beef Noodle 0 39 0 403 +Health Oil Fish Noodle Healing Fish Noodle 0 39 0 404 +Health Oil Chicken Noodle Healing Chicken Noodle 0 39 0 405 +Health Oil Rabbit Noodle Healing Rabbit Noodle 0 39 0 406 +Health Oil Mushroom Noodle Healing Mushroom Noodle 0 39 0 407 +Health Oil Ice Cream Healing Icecream 0 39 0 408 +Health Oil Green Tea Ice Cream Healing Green Tea Ice Cream 0 39 0 409 +Health Oil Holtburger Healing Holtburger 0 39 0 410 +Health Oil Hot Kimchi Healing Hot Kimchi 0 39 0 411 +Victual Oil Cake Hearty Cake 0 39 0 412 +Victual Oil Carrot Cake Hearty Carrot Cake 0 39 0 413 +Victual Oil Pizza Hearty Pizza 0 39 0 414 +Victual Oil Famous Pizza Hearty Famous Pizza 0 39 0 415 +Victual Oil Applesauce Hearty Applesauce 0 39 0 416 +Victual Oil Spiced Applesauce Hearty Spiced Applesauce 0 39 0 417 +Victual Oil Meat Pie Hearty Meat Pie 0 39 0 418 +Victual Oil Fish Pie Hearty Fish Pie 0 39 0 419 +Victual Oil Chicken Pie Hearty Chicken Pie 0 39 0 420 +Victual Oil Rabbit Pie Hearty Rabbit Pie 0 39 0 421 +Victual Oil Mushroom Pie Hearty Mushroom Pie 0 39 0 422 +Victual Oil Apple Pie Hearty Apple Pie 0 39 0 423 +Victual Oil Spiced Apple Pie Hearty Spiced Apple Pie 0 39 0 424 +Victual Oil Beef Stew Hearty Beef Stew 0 39 0 425 +Victual Oil Fish Stew Hearty Fish Stew 0 39 0 426 +Victual Oil Chicken Stew Hearty Chicken Stew 0 39 0 427 +Victual Oil Rabbit Stew Hearty Rabbit Stew 0 39 0 428 +Victual Oil Mushroom Stew Hearty Mushroom Stew 0 39 0 429 +Victual Oil Carrot Soup Hearty Carrot Soup 0 39 0 430 +Victual Oil Beef Noodle Hearty Beef Noodle 0 39 0 431 +Victual Oil Fish Noodle Hearty Fish Noodle 0 39 0 432 +Victual Oil Chicken Noodle Hearty Chicken Noodle 0 39 0 433 +Victual Oil Rabbit Noodle Hearty Rabbit Noodle 0 39 0 434 +Victual Oil Mushroom Noodle Hearty Mushroom Noodle 0 39 0 435 +Victual Oil Ice Cream Hearty Icecream 0 39 0 436 +Victual Oil Green Tea Ice Cream Hearty Green Tea Ice Cream 0 39 0 437 +Victual Oil Holtburger Hearty Holtburger 0 39 0 438 +Victual Oil Hot Kimchi Hearty Hot Kimchi 0 39 0 439 +Mana Oil Cake Mana Cake 0 39 0 440 +Mana Oil Carrot Cake Mana Carrot Cake 0 39 0 441 +Mana Oil Pizza Mana Pizza 0 39 0 442 +Mana Oil Famous Pizza Mana Famous Pizza 0 39 0 443 +Mana Oil Applesauce Mana Applesauce 0 39 0 444 +Mana Oil Spiced Applesauce Mana Spiced Applesauce 0 39 0 445 +Mana Oil Meat Pie Mana Meat Pie 0 39 0 446 +Mana Oil Fish Pie Mana Fish Pie 0 39 0 447 +Mana Oil Chicken Pie Mana Chicken Pie 0 39 0 448 +Mana Oil Rabbit Pie Mana Rabbit Pie 0 39 0 449 +Mana Oil Mushroom Pie Mana Mushroom Pie 0 39 0 450 +Mana Oil Apple Pie Mana Apple Pie 0 39 0 451 +Mana Oil Spiced Apple Pie Mana Spiced Apple Pie 0 39 0 452 +Mana Oil Beef Stew Mana Beef Stew 0 39 0 453 +Mana Oil Fish Stew Mana Fish Stew 0 39 0 454 +Mana Oil Chicken Stew Mana Chicken Stew 0 39 0 455 +Mana Oil Rabbit Stew Mana Rabbit Stew 0 39 0 456 +Mana Oil Mushroom Stew Mana Mushroom Stew 0 39 0 457 +Mana Oil Carrot Soup Mana Carrot Soup 0 39 0 458 +Mana Oil Beef Noodle Mana Beef Noodle 0 39 0 459 +Mana Oil Fish Noodle Mana Fish Noodle 0 39 0 460 +Mana Oil Chicken Noodle Mana Chicken Noodle 0 39 0 461 +Mana Oil Rabbit Noodle Mana Rabbit Noodle 0 39 0 462 +Mana Oil Mushroom Noodle Mana Mushroom Noodle 0 39 0 463 +Mana Oil Ice Cream Mana Icecream 0 39 0 464 +Mana Oil Green Tea Ice Cream Mana Green Tea Ice Cream 0 39 0 465 +Mana Oil Holtburger Mana Holtburger 0 39 0 466 +Mana Oil Hot Kimchi Mana Hot Kimchi 0 39 0 467 +Bloodhunter Oil Bundle of Greater Acid Arrowheads Bundle of Deadly Acid Arrowheads 0 37 0 468 +Bloodhunter Oil Bundle of Greater Arrowheads Bundle of Deadly Arrowheads 0 37 0 469 +Bloodhunter Oil Bundle of Greater Blunt Arrowheads Bundle of Deadly Blunt Arrowheads 0 37 0 470 +Bloodhunter Oil Bundle of Greater Frog Crotch Arrowheads Bundle of Deadly Frog Crotch Arrowheads 0 37 0 471 +Concentrated Fire Oil Wrapped Bundle of Arrowheads Wrapped Bundle of Fire Arrowheads 0 37 0 472 +Concentrated Frost Oil Wrapped Bundle of Arrowheads Wrapped Bundle of Frost Arrowheads 0 37 0 473 +Concentrated Acid Oil Wrapped Bundle of Arrowheads Wrapped Bundle of Acid Arrowheads 0 37 0 474 +Concentrated Lightning Oil Wrapped Bundle of Arrowheads Wrapped Bundle of Lightning Arrowheads 0 37 0 475 +Lightning Oil Bundle of Arrowheads Bundle of Lightning Arrowheads 0 37 0 476 +Fire Oil Bundle of Arrowheads Bundle of Fire Arrowheads 0 37 0 477 +Frost Oil Bundle of Arrowheads Bundle of Frost Arrowheads 0 37 0 478 +Acid Oil Bundle of Arrowheads Bundle of Acid Arrowheads 0 37 0 479 +Bloodseeker Oil Bundle of Blunt Arrowheads Bundle of Greater Blunt Arrowheads 0 37 0 480 +Bloodseeker Oil Bundle of Frog Crotch Arrowheads Bundle of Greater Frog Crotch Arrowheads 0 37 0 481 +Bloodseeker Oil Bundle of Arrowheads Bundle of Greater Arrowheads 0 37 0 482 +Bloodseeker Oil Bundle of Fire Arrowheads Bundle of Greater Fire Arrowheads 0 37 0 483 +Bloodseeker Oil Bundle of Acid Arrowheads Bundle of Greater Acid Arrowheads 0 37 0 484 +Bloodseeker Oil Bundle of Frost Arrowheads Bundle of Greater Frost Arrowheads 0 37 0 485 +Bloodseeker Oil Bundle of Lightning Arrowheads Bundle of Greater Lightning Arrowheads 0 37 0 486 +Eye Dropper Concentrated Health Infusion Health Infusion 0 38 0 487 +Eye Dropper Concentrated Mana Infusion Mana Infusion 0 38 0 488 +Eye Dropper Concentrated Victual Infusion Victual Infusion 0 38 0 489 +Alembic Quicksilver Bloodseeker Infusion 0 38 0 490 +Alembic Stibnite Bloodhunter Infusion 0 38 0 491 +Alembic Cobalt Lightning Infusion 0 38 0 492 +Alembic Turpeth Fire Infusion 0 38 0 493 +Alembic Colcothar Frost Infusion 0 38 0 494 +Alembic Brimstone Acid Infusion 0 38 0 495 +Alembic Vitriol Pea Concentrated Health Infusion 0 38 0 496 +Alembic Gypsum Pea Concentrated Mana Infusion 0 38 0 497 +Alembic Realgar Pea Concentrated Victual Infusion 0 38 0 498 +Alembic Quicksilver Pea Concentrated Bloodseeker Infusion 0 38 0 499 +Alembic Stibnite Pea Concentrated Bloodhunter Infusion 0 38 0 500 +Alembic Turpeth Pea Concentrated Fire Infusion 0 38 0 501 +Alembic Colcothar Pea Concentrated Frost Infusion 0 38 0 502 +Alembic Brimstone Pea Concentrated Acid Infusion 0 38 0 503 +Alembic Cobalt Pea Concentrated Lightning Infusion 0 38 0 504 +Crucible Stibnite Crucible with Stibnite Potion 0 38 0 505 +Crucible with Stibnite Potion Frankincense Stibnite and Frankincense Crucible 0 38 0 506 +Aqua Vitae Stibnite and Frankincense Crucible Treated Stibnite and Frankincense Crucible 0 38 0 507 +Crucible Quicksilver Crucible with Quicksilver Potion 0 38 0 508 +Crucible with Quicksilver Potion Frankincense Quicksilver and Frankincense Crucible 0 38 0 509 +Aqua Vitae Quicksilver and Frankincense Crucible Treated Quicksilver and Frankincense Crucible 0 38 0 510 +Crucible Verdigris Crucible with Verdigris Potion 0 38 0 511 +Crucible with Verdigris Potion Frankincense Verdigris and Frankincense Crucible 0 38 0 512 +Aqua Vitae Verdigris and Frankincense Crucible Treated Verdigris and Frankincense Crucible 0 38 0 513 +Crucible Cadmia Crucible with Cadmia Potion 0 38 0 514 +Crucible with Cadmia Potion Frankincense Cadmia and Frankincense Crucible 0 38 0 515 +Aqua Vitae Cadmia and Frankincense Crucible Treated Cadmia and Frankincense Crucible 0 38 0 516 +Crucible Brimstone Crucible with Brimstone Potion 0 38 0 517 +Crucible with Brimstone Potion Frankincense Brimstone and Frankincense Crucible 0 38 0 518 +Aqua Vitae Brimstone and Frankincense Crucible Treated Brimstone and Frankincense Crucible 0 38 0 519 +Crucible Colcothar Crucible with Colcothar Potion 0 38 0 520 +Crucible with Colcothar Potion Frankincense Colcothar and Frankincense Crucible 0 38 0 521 +Aqua Vitae Colcothar and Frankincense Crucible Treated Colcothar and Frankincense Crucible 0 38 0 522 +Crucible Turpeth Crucible with Turpeth Potion 0 38 0 523 +Crucible with Turpeth Potion Frankincense Turpeth and Frankincense Crucible 0 38 0 524 +Aqua Vitae Turpeth and Frankincense Crucible Treated Turpeth and Frankincense Crucible 0 38 0 525 +Crucible Cobalt Crucible with Cobalt Potion 0 38 0 526 +Crucible with Cobalt Potion Frankincense Cobalt and Frankincense Crucible 0 38 0 527 +Aqua Vitae Cobalt and Frankincense Crucible Treated Cobalt and Frankincense Crucible 0 38 0 528 +Crucible Vitriol Crucible with Vitriol Potion 0 38 0 529 +Crucible with Vitriol Potion Frankincense Vitriol and Frankincense Crucible 0 38 0 530 +Aqua Vitae Vitriol and Frankincense Crucible Treated Vitriol and Frankincense Crucible 0 38 0 531 +Crucible Cinnabar Crucible with Cinnabar Potion 0 38 0 532 +Crucible with Cinnabar Potion Frankincense Cinnabar and Frankincense Crucible 0 38 0 533 +Aqua Vitae Cinnabar and Frankincense Crucible Treated Cinnabar and Frankincense Crucible 0 38 0 534 +Crucible Gypsum Crucible with Gypsum Potion 0 38 0 535 +Crucible with Gypsum Potion Frankincense Gypsum and Frankincense Crucible 0 38 0 536 +Aqua Vitae Gypsum and Frankincense Crucible Treated Gypsum and Frankincense Crucible 0 38 0 537 +Ground Chorizite Vitriol Chorizite 0 0 0 538 +Alembic Chorizite Chorizite Oil 0 0 0 539 +Chorizite Oil Chorizite Oil Strong Chorizite Oil 0 0 0 540 +Chorizite Oil Strong Chorizite Oil Concentrated Chorizite Oil 0 0 0 541 +Chorizite Oil Concentrated Chorizite Oil Condensed Chorizite Oil 0 0 0 542 +Cocoa Mixture Milk Milky Cocoa Mixture 0 39 0 543 +Mortar and Pestle Cinnamon Bark Cinnamon 0 0 0 544 +Heavy Grinder Ginger Ground Ginger 0 0 0 545 +Mortar and Pestle Hot Pepper Hot Sauce 0 0 0 546 +Heavy Grinder Nutmeg Ground Nutmeg 0 0 0 547 +Flour Water Dough 0 0 0 548 +Carving Knife Fish Fish Filet 0 39 0 549 +Carving Knife Brimstone-cap Mushroom Stemless Mushroom 0 39 0 550 +Rennet Milk Cheese 0 39 0 551 +Stemless Mushroom Cheese Cheese Filled Mushroom 0 39 0 552 +Dough Egg Batter 0 39 0 553 +Baking Pan Brown Beans Roasted Beans 0 39 0 554 +Heavy Grinder Roasted Beans Chocolate Liquor 0 39 0 555 +Metal Press Chocolate Liquor Cocoa Powder 0 39 0 556 +Cocoa Powder Milk Bitter Milk 0 39 0 557 +Heavy Grinder Magic Iceball Crushed Ice 0 39 0 558 +Ground Nutmeg Milk Spiced Milk 0 39 0 559 +Cooking Pot Milk Hot Milk 0 39 0 560 +Hot Milk Honey Sweetened Hot Milk 0 39 0 561 +Cocoa Powder Coffee Mocha Base 0 39 0 562 +Whittling Knife Strange Stick Cinnamon Bark 0 39 0 563 +Carving Knife Bread Slice of Bread 0 39 0 564 +Carving Knife Side of Beef Steak 0 39 0 565 +Heavy Grinder Steak Ground Meat 0 39 0 566 +Heavy Grinder Rabbit Piece Ground Rabbit 0 39 0 567 +Batter Flour Cake Batter 0 39 0 568 +Cake Batter Carrot Carrot Cake Batter 0 39 0 569 +Cake Batter Cocoa Powder Chocolate Cake Batter 0 39 0 570 +Dough Honey Cookie Dough 0 39 0 571 +Cocoa Powder Cookie Dough Chocolate Cookie Dough 0 39 0 572 +Chocolate Liquor Cocoa Powder Cocoa Mixture 0 39 0 573 +Cinnamon Mocha Rich Mocha 0 39 0 574 +Cinnamon Brown Lump Spiced Lump 0 39 0 575 +Flour Spiced Lump Spiced Lumpy Flour 0 39 0 576 +Spiced Lumpy Flour Egg Rich Lumpy Flour 0 39 0 577 +Rich Lumpy Flour Red Wine Fruitcake Batter 0 39 0 578 +Ground Ginger Dough Ginger Dough 0 39 0 579 +Frozen Cream Green Tea Frozen Green Tea 0 39 0 580 +Magic Iceball Milk Frozen Cream 0 39 0 581 +Peppermint Stick Chocolate Cookie Dough Peppermint Chocolate Cookie Dough 0 39 0 582 +Peppermint Stick Cookie Dough Peppermint Cookie Dough 0 39 0 583 +Baking Pan Pumpkin Cooked Pumpkin 0 39 0 584 +Cooked Pumpkin Milk Liquid Pumpkin 0 39 0 585 +Liquid Pumpkin Honey Sweetened Pumpkin 0 39 0 586 +Sweetened Pumpkin Cinnamon Spiced Pumpkin 0 39 0 587 +Spiced Pumpkin Egg Pumpkin Pie Filling 0 39 0 588 +Cinnamon Apple Spiced Apple Filling 0 39 0 589 +Carving Knife Chicken Chicken Pieces 0 39 0 590 +Carving Knife Rabbit Carcass Rabbit Pieces 0 39 0 591 +Noodle Cutter Dough Raw Noodles 0 39 0 592 +Dough Olthoi Egg Olthoi Batter 0 39 0 593 +Flour Olthoi Batter Olthoi Cake Batter 0 39 0 594 +Olthoi Cake Batter Carrot Olthoi Carrot Cake Batter 0 39 0 595 +Olthoi Cake Batter Chocolate Powder Olthoi Chocolate Cake Batter 0 39 0 596 +Spiced Pumpkin Filling Olthoi Egg Olthoi Pumpkin Pie Filling 0 39 0 597 +Cooking Pot Carrot Carrot Stock 0 39 0 598 +Carrot Stock Milk Rich Carrot Stock 0 39 0 599 +Mortar and Pestle Uncooked Rice Rice Flour 0 39 0 600 +Rice Flour Water Rice Dough 0 39 0 601 +Carving Knife Carrot Cake Cubed Carrot Cake 0 39 0 602 +Noodle Cutter Batter Raw Egg Noodles 0 39 0 603 +Baking Pan Plain Barley Roasted Barley 0 39 0 604 +Brew Kettle Water Full Brew Kettle 0 39 0 605 +Roasted Barley Full Brew Kettle Dark Wort 0 39 0 606 +Ultra Green Hops Dark Wort Aromatic Dark Wort 0 39 0 607 +Dried Yeast Aromatic Dark Wort Glorious Dark Brew 0 39 0 608 +Amber Barley Full Brew Kettle Amber Wort 0 39 0 609 +Ultra Green Hops Amber Wort Aromatic Amber Wort 0 39 0 610 +Dried Yeast Aromatic Amber Wort Glorious Amber Brew 0 39 0 611 +Plain Barley Full Brew Kettle Sweet Wort 0 39 0 612 +Ultra Green Hops Sweet Wort Aromatic Finished Wort 0 39 0 613 +Dried Yeast Aromatic Finished Wort Glorious Fermented Brew 0 39 0 614 +Moarsmuck Glorious Dark Brew Apothecary Zongo's Stout Brew 0 39 0 615 +Moarsmuck Glorious Amber Brew Hunter's Stock Amber Brew 0 39 0 616 +Moarsmuck Glorious Fermented Brew Duke Raoul's Distillation Brew 0 39 0 617 +Tusker Spit Glorious Dark Brew Bobo's Stout Brew 0 39 0 618 +Tusker Spit Glorious Amber Brew Amber Ape Brew 0 39 0 619 +Tusker Spit Glorious Fermented Brew Tusker Spit Brew 0 39 0 620 +Wrapped Bundle of Raider Lightning Arrowheads Wrapped Bundle of Arrowshafts Raider Lightning Arrow 250 37 0 621 +Wrapped Bundle of Raider Lightning Arrowheads Wrapped Bundle of Quarrelshafts Raider Lightning Bolt 250 37 0 622 +Wrapped Bundle of Raider Lightning Arrowheads Wrapped Bundle of Atlatl Dartshafts Raider Lightning Atlatl Dart 250 37 0 623 +Wrapped Bundle of Arrowheads Wrapped Bundle of Arrowshafts Arrow 250 37 0 624 +Wrapped Bundle of Arrowheads Wrapped Bundle of Atlatl Dartshafts Atlatl Dart 250 37 0 625 +Wrapped Bundle of Arrowheads Wrapped Bundle of Quarrelshafts Quarrel 250 37 0 626 +Carving Knife Cured Mushroom Stalk Tiriun Stalk Jerky 10 39 100 627 +Hot Sauce Tiriun Mushroom Stalk Cured Mushroom Stalk 1 39 100 628 +Cooking Pot Tiriun Mushroom Spores Roasted Tiriun Spores 1 39 100 629 +Mortar and Pestle Roasted Tiriun Spores Tiriun Spore Powder 10 39 100 630 +Skewer Tiriun Mushroom Cap Roasted Tiriun Cap 1 39 100 631 +Carving Knife Roasted Tiriun Cap Tiriun Cap Wafer 10 39 100 632 +Splitting Tool Lead Pea Lead Scarab 20 33 0 633 +Splitting Tool Iron Pea Iron Scarab 20 33 0 634 +Splitting Tool Copper Pea Copper Scarab 20 33 0 635 +Splitting Tool Silver Pea Silver Scarab 20 33 0 636 +Splitting Tool Gold Pea Gold Scarab 20 33 0 637 +Splitting Tool Pyreal Pea Pyreal Scarab 20 33 0 638 +Splitting Tool Amaranth Pea Amaranth 50 33 0 639 +Splitting Tool Bistort Pea Bistort 50 33 0 640 +Splitting Tool Comfrey Pea Comfrey 50 33 0 641 +Splitting Tool Damiana Pea Damiana 50 33 0 642 +Splitting Tool Dragonsblood Pea Dragonsblood 50 33 0 643 +Splitting Tool Eyebright Pea Eyebright 50 33 0 644 +Splitting Tool Frankincense Pea Frankincense 50 33 0 645 +Splitting Tool Ginseng Pea Ginseng 50 33 0 646 +Splitting Tool Hawthorn Pea Hawthorn 50 33 0 647 +Splitting Tool Henbane Pea Henbane 50 33 0 648 +Splitting Tool Hyssop Pea Hyssop 50 33 0 649 +Splitting Tool Mandrake Pea Mandrake 50 33 0 650 +Splitting Tool Mugwort Pea Mugwort 50 33 0 651 +Splitting Tool Myrrh Pea Myrrh 50 33 0 652 +Splitting Tool Saffron Pea Saffron 50 33 0 653 +Splitting Tool Vervain Pea Vervain 50 33 0 654 +Splitting Tool Wormwood Pea Wormwood 50 33 0 655 +Splitting Tool Yarrow Pea Yarrow 50 33 0 656 +Splitting Tool Powdered Agate Pea Powdered Agate 50 33 0 657 +Splitting Tool Powdered Amber Pea Powdered Amber 50 33 0 658 +Splitting Tool Powdered Azurite Pea Powdered Azurite 50 33 0 659 +Splitting Tool Powdered Bloodstone Pea Powdered Bloodstone 50 33 0 660 +Splitting Tool Powdered Carnelian Pea Powdered Carnelian 50 33 0 661 +Splitting Tool Powdered Hematite Pea Powdered Hematite 50 33 0 662 +Splitting Tool Powdered Lapis Lazuli Pea Powdered Lapis Lazuli 50 33 0 663 +Splitting Tool Powdered Malachite Pea Powdered Malachite 50 33 0 664 +Splitting Tool Powdered Moonstone Pea Powdered Moonstone 50 33 0 665 +Splitting Tool Powdered Onyx Pea Powdered Onyx 50 33 0 666 +Splitting Tool Powdered Quartz Pea Powdered Quartz 50 33 0 667 +Splitting Tool Powdered Turquoise Pea Powdered Turquoise 50 33 0 668 +Splitting Tool Brimstone Pea Brimstone 50 33 0 669 +Splitting Tool Cadmia Pea Cadmia 50 33 0 670 +Splitting Tool Cinnabar Pea Cinnabar 50 33 0 671 +Splitting Tool Cobalt Pea Cobalt 50 33 0 672 +Splitting Tool Colcothar Pea Colcothar 50 33 0 673 +Splitting Tool Gypsum Pea Gypsum 50 33 0 674 +Splitting Tool Quicksilver Pea Quicksilver 50 33 0 675 +Splitting Tool Realgar Pea Realgar 50 33 0 676 +Splitting Tool Stibnite Pea Stibnite 50 33 0 677 +Splitting Tool Turpeth Pea Turpeth 50 33 0 678 +Splitting Tool Verdigris Pea Verdigris 50 33 0 679 +Splitting Tool Vitriol Pea Vitriol 50 33 0 680 +Splitting Tool Poplar Pea Poplar Talisman 20 33 0 681 +Splitting Tool Blackthorn Pea Blackthorn Talisman 20 33 0 682 +Splitting Tool Yew Pea Yew Talisman 20 33 0 683 +Splitting Tool Hemlock Pea Hemlock Talisman 20 33 0 684 +Splitting Tool Alder Pea Alder Talisman 20 33 0 685 +Splitting Tool Ebony Pea Ebony Talisman 20 33 0 686 +Splitting Tool Birch Pea Birch Talisman 20 33 0 687 +Splitting Tool Ashwood Pea Ashwood Talisman 20 33 0 688 +Splitting Tool Elder Pea Elder Talisman 20 33 0 689 +Splitting Tool Rowan Pea Rowan Talisman 20 33 0 690 +Splitting Tool Willow Pea Willow Talisman 20 33 0 691 +Splitting Tool Cedar Pea Cedar Talisman 20 33 0 692 +Splitting Tool Oak Pea Oak Talisman 20 33 0 693 +Splitting Tool Hazel Pea Hazel Talisman 20 33 0 694 +Splitting Tool Red Pea Red Taper 50 33 0 695 +Splitting Tool Pink Pea Pink Taper 50 33 0 696 +Splitting Tool Orange Pea Orange Taper 50 33 0 697 +Splitting Tool Yellow Pea Yellow Taper 50 33 0 698 +Splitting Tool Green Pea Green Taper 50 33 0 699 +Splitting Tool Turquoise Pea Turquoise Taper 50 33 0 700 +Splitting Tool Blue Pea Blue Taper 50 33 0 701 +Splitting Tool Indigo Pea Indigo Taper 50 33 0 702 +Splitting Tool Violet Pea Violet Taper 50 33 0 703 +Splitting Tool Brown Pea Brown Taper 50 33 0 704 +Splitting Tool White Pea White Taper 50 33 0 705 +Splitting Tool Grey Pea Grey Taper 50 33 0 706 +Wrapped Bundle of Greater Deadly Blunt Arrowheads Wrapped Bundle of Arrowshafts Greater Deadly Blunt Arrow 250 37 0 707 +Wrapped Bundle of Greater Deadly Blunt Arrowheads Wrapped Bundle of Quarrelshafts Greater Deadly Blunt Quarrel 250 37 0 708 +Wrapped Bundle of Greater Deadly Blunt Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Deadly Blunt Atlatl Dart 250 37 0 709 +Wrapped Bundle of Olthoi Acid Arrowheads Wrapped Bundle of Arrowshafts Olthoi Acid Arrow 2500 37 0 710 +Wrapped Bundle of Olthoi Acid Arrowheads Wrapped Bundle of Quarrelshafts Olthoi Acid Bolt 2500 37 0 711 +Wrapped Bundle of Olthoi Acid Arrowheads Wrapped Bundle of Atlatl Dartshafts Olthoi Acid Atlatl Dart 2500 37 0 712 +Wrapped Bundle of Gear Blade Slashing Arrowheads Wrapped Bundle of Arrowshafts Gear Blade Slashing Arrow 250 37 0 713 +Wrapped Bundle of Gear Blade Slashing Arrowheads Wrapped Bundle of Quarrelshafts Gear Blade Slashing Bolt 250 37 0 714 +Wrapped Bundle of Gear Blade Slashing Arrowheads Wrapped Bundle of Atlatl Dartshafts Gear Blade Slashing Atlatl Dart 250 37 0 715 +Wrapped Bundle of Greater Deadly Armor Piercing Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Deadly Armor Piercing Atlatl Dart 500 37 0 716 +Wrapped Bundle of Greater Deadly Armor Piercing Arrowheads Wrapped Bundle of Quarrelshafts Greater Deadly Armor Piercing Quarrel 500 37 0 717 +Wrapped Bundle of Greater Deadly Armor Piercing Arrowheads Wrapped Bundle of Arrowshafts Greater Deadly Armor Piercing Arrow 500 37 0 718 +Wrapped Bundle of Burning Sands Arrowheads Wrapped Bundle of Atlatl Dartshafts Burning Sands Atlatl Dart 500 37 0 719 +Wrapped Bundle of Burning Sands Arrowheads Wrapped Bundle of Quarrelshafts Burning Sands Bolt 500 37 0 720 +Wrapped Bundle of Burning Sands Arrowheads Wrapped Bundle of Arrowshafts Burning Sands Arrow 500 37 0 721 +Wrapped Bundle of Greater Deadly Frog Crotch Arrowheads Wrapped Bundle of Arrowshafts Greater Deadly Frog Crotch Arrow 500 37 0 722 +Wrapped Bundle of Greater Deadly Frog Crotch Arrowheads Wrapped Bundle of Quarrelshafts Greater Deadly Frog Crotch Quarrel 500 37 0 723 +Wrapped Bundle of Greater Deadly Frog Crotch Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Deadly Frog Crotch Atlatl Dart 500 37 0 724 +Wrapped Bundle of Deadly Prismatic Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Prismatic Atlatl Dart 500 37 0 725 +Wrapped Bundle of Deadly Prismatic Arrowheads Wrapped Bundle of Quarrelshafts Deadly Prismatic Quarrel 500 37 0 726 +Wrapped Bundle of Deadly Prismatic Arrowheads Wrapped Bundle of Arrowshafts Deadly Prismatic Arrow 500 37 0 727 +Wrapped Bundle of Greater Prismatic Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Prismatic Atlatl Dart 500 37 0 728 +Wrapped Bundle of Greater Prismatic Arrowheads Wrapped Bundle of Quarrelshafts Greater Prismatic Quarrel 500 37 0 729 +Wrapped Bundle of Greater Prismatic Arrowheads Wrapped Bundle of Arrowshafts Greater Prismatic Arrow 500 37 0 730 +Wrapped Bundle of Prismatic Arrowheads Wrapped Bundle of Atlatl Dartshafts Prismatic Atlatl Dart 500 37 0 731 +Wrapped Bundle of Prismatic Arrowheads Wrapped Bundle of Quarrelshafts Prismatic Quarrel 500 37 0 732 +Wrapped Bundle of Prismatic Arrowheads Wrapped Bundle of Arrowshafts Prismatic Arrow 500 37 0 733 +Infinite Deadly Frog Crotch Arrowheads Wrapped Bundle of Arrowshafts Deadly Frog Crotch Arrow 500 37 0 734 +Infinite Deadly Broad Arrowheads Wrapped Bundle of Arrowshafts Deadly Broadhead Arrow 500 37 0 735 +Infinite Deadly Armor Piercing Arrowheads Wrapped Bundle of Arrowshafts Deadly Armor Piercing Arrow 500 37 0 736 +Infinite Deadly Blunt Arrowheads Wrapped Bundle of Arrowshafts Deadly Blunt Arrow 500 37 0 737 +Infinite Deadly Acid Arrowheads Wrapped Bundle of Arrowshafts Deadly Acid Arrow 500 37 0 738 +Infinite Deadly Fire Arrowheads Wrapped Bundle of Arrowshafts Deadly Fire Arrow 500 37 0 739 +Infinite Deadly Frost Arrowheads Wrapped Bundle of Arrowshafts Deadly Frost Arrow 500 37 0 740 +Infinite Deadly Electric Arrowheads Wrapped Bundle of Arrowshafts Deadly Lightning Arrow 500 37 0 741 +Infinite Deadly Frog Crotch Arrowheads Wrapped Bundle of Quarrelshafts Deadly Frog Crotch Quarrel 500 37 0 742 +Infinite Deadly Frog Crotch Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Frog Crotch Atlatl Dart 500 37 0 743 +Infinite Deadly Broad Arrowheads Wrapped Bundle of Quarrelshafts Deadly Broadhead Quarrel 500 37 0 744 +Infinite Deadly Broad Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Broadhead Atlatl Dart 500 37 0 745 +Infinite Deadly Armor Piercing Arrowheads Wrapped Bundle of Quarrelshafts Deadly Armor Piercing Quarrel 500 37 0 746 +Infinite Deadly Armor Piercing Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Armor Piercing Atlatl Dart 500 37 0 747 +Infinite Deadly Blunt Arrowheads Wrapped Bundle of Quarrelshafts Deadly Blunt Quarrel 500 37 0 748 +Infinite Deadly Blunt Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Blunt Atlatl Dart 500 37 0 749 +Infinite Deadly Acid Arrowheads Wrapped Bundle of Quarrelshafts Deadly Acid Quarrel 500 37 0 750 +Infinite Deadly Acid Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Acid Atlatl Dart 500 37 0 751 +Infinite Deadly Fire Arrowheads Wrapped Bundle of Quarrelshafts Deadly Fire Quarrel 500 37 0 752 +Infinite Deadly Fire Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Fire Atlatl Dart 500 37 0 753 +Infinite Deadly Frost Arrowheads Wrapped Bundle of Quarrelshafts Deadly Frost Quarrel 500 37 0 754 +Infinite Deadly Frost Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Frost Atlatl Dart 500 37 0 755 +Infinite Deadly Electric Arrowheads Wrapped Bundle of Quarrelshafts Deadly Lightning Quarrel 500 37 0 756 +Infinite Deadly Electric Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Lightning Atlatl Dart 500 37 0 757 diff --git a/src/AcDream.Plugins.MossTank/VtankDamageDatabase.cs b/src/AcDream.Plugins.MossTank/VtankDamageDatabase.cs new file mode 100644 index 00000000..c6c7dede --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankDamageDatabase.cs @@ -0,0 +1,289 @@ +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// +/// The ordered monster-element preferences from VTank's official +/// GameInfoDB. Name overrides win over CreatureType, matching e0.d(name). +/// Unknown targets retain VTank's final 0..6 element fallback order. +/// +internal static class VtankDamageDatabase +{ + private static readonly MonsterDamageType[] Fallback = + [ + MonsterDamageType.Pierce, + MonsterDamageType.Bludgeon, + MonsterDamageType.Slash, + MonsterDamageType.Acid, + MonsterDamageType.Electric, + MonsterDamageType.Cold, + MonsterDamageType.Fire, + ]; + + private static readonly Dictionary Overrides = + ParseNames(OverrideData); + private static readonly Dictionary Species = + ParseSpecies(SpeciesData); + + public static IReadOnlyList Preferences( + in PluginCombatTarget target) + { + if (!string.IsNullOrWhiteSpace(target.Name) + && Overrides.TryGetValue(target.Name, out MonsterDamageType[]? exact)) + { + return exact; + } + + // Zero is also the plugin contract's "not appraised" sentinel. + if (target.SpeciesId != 0 + && Species.TryGetValue(target.SpeciesId, out MonsterDamageType[]? species)) + { + return species; + } + return Fallback; + } + + public static int PreferenceIndex( + in PluginCombatTarget target, + MonsterDamageType damage) + { + IReadOnlyList preferences = Preferences(target); + for (int i = 0; i < preferences.Count; i++) + { + if (preferences[i] == damage) + return i; + } + + for (int i = 0; i < Fallback.Length; i++) + { + if (Fallback[i] == damage) + return preferences.Count + i; + } + return int.MaxValue; + } + + private static Dictionary ParseNames( + string data) + { + var result = new Dictionary( + StringComparer.OrdinalIgnoreCase); + foreach (ReadOnlySpan line in data.AsSpan().EnumerateLines()) + { + int separator = line.IndexOf('|'); + if (separator <= 0) + continue; + result[line[..separator].ToString()] = ParseElements( + line[(separator + 1)..]); + } + return result; + } + + private static Dictionary ParseSpecies( + string data) + { + var result = new Dictionary(); + foreach (ReadOnlySpan line in data.AsSpan().EnumerateLines()) + { + int separator = line.IndexOf('|'); + if (separator <= 0 + || !int.TryParse(line[..separator], out int species)) + { + continue; + } + result[species] = ParseElements(line[(separator + 1)..]); + } + return result; + } + + private static MonsterDamageType[] ParseElements(ReadOnlySpan text) + { + var result = new List(7); + foreach (Range range in text.Split(';')) + { + if (!int.TryParse(text[range], out int raw)) + continue; + MonsterDamageType mapped = raw switch + { + 0 => MonsterDamageType.Pierce, + 1 => MonsterDamageType.Bludgeon, + 2 => MonsterDamageType.Slash, + 3 => MonsterDamageType.Acid, + 4 => MonsterDamageType.Electric, + 5 => MonsterDamageType.Cold, + 6 => MonsterDamageType.Fire, + _ => MonsterDamageType.None, + }; + if (mapped != MonsterDamageType.None && !result.Contains(mapped)) + result.Add(mapped); + } + return [.. result]; + } + + private const string OverrideData = """ +Magma Golem|5;1;0;2 +Mist Golem|5;4;3;6;2;0;1 +Nubilous Golem|4;5;3;2;0;1 +Plasma Golem|4;5;3;2;0;1 +Vapor Golem|5;4;15;4;3;6;2;0;1 +Damaged Glacial Golem|6;1;0;2 +Fractured Glacial Golem|6;1;0;2 +Tanada Nanjou Shou-jen|3;4;6;5 +Disgraced Nanjou Shou-jen|3;4;6;5 +Magma Golem Exarch|5;1;0;2 +Pillar of Fire|5;2;0 +Infused Blood Golem|5;3;4;1;0;6;2 +Infused Empyrean Blood Golem|5;3;4;2;6;0;1 +Sapphire Golem|0;3;1;5;6;4;2 +High Priestess Xik Minru|1;2;0 +Contained Rift|2;1;0 +Ebon Rift|2;1;0 +Fallen Rift|2;1;0 +Narrow Rift|2;1;0 +Quiddity Rift|2;1;0 +Shallow Rift|2;1;0 +Tenebrous Rift|2;1;0 +Umbral Rift|2;1;0 +Unstable Rift|2;1;0 +Aqueous Golem|4;6;5;3;2;1;0 +Wave Golem|4;6;5;3;2;1;0 +Unstable Magma Golem|5;1;0;2 +Behemoth of Tenkarrdun|5;1;0;2 +Small Magma Golem|5;1;0;2 +Atlan's Crafting Golem|5;1;0;2 +Bur Lizk|5;4;0;2 +Dust Golem|5;4;6;3;0;1;2 +Ancient Magma Golem|5;1;0;2 +Frozen Ice Golem|6;1;0;2 +Frozen Glacial Golem|6;1;0;2 +Forge Golem|5;4;3;1;0;2 +Frozen Gearknight|6 +Diaphanous Nephol Golem|5;4;3;6;2;0;1 +Tenuous Nephol Golem|5;4;3;6;2;0;1 +Turbid Nephol Golem|5;4;3;6;2;0;1 +Wall of Ice|6;1;0;2;3;4 +Scold|5;1;0;2 +Scold Chunk|5;1;0;2 +Scold Lump|5;1;0;2 +Freezing Mist Golem|5;4;3;6;2;0;1 +Frost Golem|6;4;5;3 +Elite Guardian|5;6 +Enraged Ancient Soul|6;3;1;4;5;2;0 +Mudmouth|6;4;3;5;1;0;2 +Fiery Defender|5;2;4;1;0;3 +Follower of Deewain|4;3;1;0;5;2;6 +Chilled Defender|4;3;6;1;2;0;5 +Charged Defender|5;3;4;2;1;0 +Iron Golem Samurai|3;4;5;6;2;1;0 +Clay Golem Samurai|1;5;6;4;3;2;0 +Bronze Golem Samurai|4;3;5;6;2;1;0 +Spectral Nanjou Shou-jen|1;6;2;3;4;5;0 +Spectral Samurai|5;4;3;2;1;6;0 +Spectral Claw Master|1;6;2;3;4;5;0 +"""; + + private const string SpeciesData = """ +0|2 +1|1;0;2;5;6;4;3 +2|4;6;5;2;0;1;3 +3|6;1;2;3;5;0;4 +4|6;1;4;3;2;5;0 +5|4;3;2;0;1;5;6 +6|2;0;5;3;1;4;6 +7|0;2;1;6;3;5;4 +8|6;0;1;5;3;2;4 +9|1;2;0;3;6;5;4 +10|1;4;2;0;5;3;6 +11|2 +12|2 +13|1;3;0;5;6;4;2 +14|6;3;2;1;4;0;5 +15|2;0;1 +16|5;2;4;3;0;1;6 +17|2;0;3;1;5;4;6 +18|2 +19|6;0;1;2;3;5;4 +20|2;0;1;3;4;5 +21|0;4;6;3 +22|6;2;1;4;0;3;5 +23|6;0;1;3;2;5;4 +24|6;3;2 +25|2 +26|5;2;0;6 +27|0;2;1 +28|5;3;0 +29|4;5;2 +30|1;2;0 +31|6;0;1;2;3;4;5 +32|5;1;3 +33|2;0;1 +34|2;0;1 +35|1;0;2 +36|2;6;0 +37|2 +38|5;2;0 +39|6;2;1 +40|2 +41|2 +42|3;2;0 +43|2 +44|2;0;1 +45|2;5;3 +46|6;0;2;1;3;4;5 +47|1;0;2 +48|5;2;0;3;1;6;4 +49|6;2;0;1 +50|2;6;1 +51|2;1;0 +52|6;2;1;0 +53|5;1;2;4;6;3;0 +54|5;2;0;1 +55|1;5;4;2;6;3;0 +56|5;1;3 +57|2;0;5;3;1;4;6 +58|2;0;5;3;1;4;6 +59|5;2;0;3;1;6;4 +60|4;2;0 +61|6;2;0 +62|2;0;1 +63|3;4;6;5;1;2;0 +64|2 +65|2 +66|2 +67|2 +68|2 +69|2 +70|4;3;0;2;1 +71|1;5;4;0;2;6;3 +72|2 +73|2 +74|2 +75|5;0;2;6;1;4;3 +76|2 +77|6;2;0;1;3;4;5 +78|4;6;5;2;3;0;1 +79|2;0;6;5;4;3;1 +80|6;2;1;0 +81|1;0;6;2;3;4;5 +82|2;1;0 +83|4;2;0;1 +84|1;2;0 +85|2 +86|2;0;1 +87|2 +88|1;0;2 +89|0;1;2 +90|2 +91|2 +92|1;0;2 +93|2 +94|2 +95|6;2;1;0 +96|2 +-1|1;0;2;3;4;5;6 +97|6 +98|2;0;1 +99|3;4;1;0;6;5;2 +100|6;3;2;0;1;5;4 +101|3;1;6;0;2;4;5 +"""; +} diff --git a/src/AcDream.Plugins.MossTank/VtankLootProfileSerializer.cs b/src/AcDream.Plugins.MossTank/VtankLootProfileSerializer.cs new file mode 100644 index 00000000..fdf8ab82 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankLootProfileSerializer.cs @@ -0,0 +1,446 @@ +using System.Globalization; +using System.Text; + +namespace AcDream.Plugins.MossTank; + +/// +/// One length-delimited VTClassic requirement. The payload is retained +/// verbatim so a newer VTClassic requirement can survive an acdream edit even +/// when MossTank does not understand that requirement yet. +/// +internal sealed class VtankLootRequirement +{ + public int Type { get; set; } + public string Payload { get; set; } = string.Empty; +} + +internal sealed class VtankSalvageCombineSettings +{ + public string DefaultCombineString { get; set; } = "1-6, 7-8, 9, 10"; + public Dictionary MaterialCombineStrings { get; set; } = + CreateVtankDefaults(); + public Dictionary MaterialValueModeValues { get; set; } = []; + + public VtankSalvageCombineSettings Clone() => new() + { + DefaultCombineString = DefaultCombineString, + MaterialCombineStrings = new Dictionary( + MaterialCombineStrings), + MaterialValueModeValues = new Dictionary( + MaterialValueModeValues), + }; + + private static Dictionary CreateVtankDefaults() + { + const string oneThroughTen = "1-10"; + int[] materials = + [ + 10, 14, 16, 17, 18, 19, 22, 25, 29, 30, 36, 37, 41, + 47, 35, 27, 26, 21, 15, 13, + 50, 49, 34, + 52, 51, + ]; + return materials.ToDictionary( + static material => material, + static _ => oneThroughTen); + } +} + +internal sealed class VtankLootExtraBlock +{ + public string Type { get; set; } = string.Empty; + public string Payload { get; set; } = string.Empty; +} + +internal sealed class VtankLootProfile +{ + public int SourceVersion { get; set; } = 1; + public List Rules { get; set; } = []; + public VtankSalvageCombineSettings SalvageCombine { get; set; } = new(); + public List UnknownBlocks { get; set; } = []; +} + +/// +/// Independent reader/writer for VTClassic's public UTL 1 format. +/// The format was recovered from the MIT-licensed VTClassic source; this is a +/// clean implementation using MossTank's own model and parser. +/// +internal static class VtankLootProfileSerializer +{ + private const string Header = "UTL"; + private const int CurrentVersion = 1; + private const string SalvageBlock = "SalvageCombine"; + private const int DisabledRuleType = 9999; + private static readonly string NewLine = "\r\n"; + + public static bool TryRead( + string? source, + out VtankLootProfile profile, + out string error) + { + profile = new VtankLootProfile(); + error = string.Empty; + if (string.IsNullOrEmpty(source)) + { + error = "The VTClassic loot profile is empty."; + return false; + } + + try + { + var reader = new CharacterReader(source); + string first = reader.ReadLine(); + int version; + int count; + if (string.Equals(first, Header, StringComparison.Ordinal)) + { + version = ParseInt(reader.ReadLine(), "profile version"); + if (version is < 0 or > CurrentVersion) + throw new FormatException( + $"VTClassic loot profile version {version} is not supported."); + count = ParseCount(reader.ReadLine(), "rule count", 100_000); + } + else + { + version = 0; + count = ParseCount(first, "rule count", 100_000); + } + + profile.SourceVersion = version; + for (int index = 0; index < count; index++) + profile.Rules.Add(ReadRule(reader, version, index)); + + while (!reader.End) + { + string blockType = reader.ReadLine(); + if (blockType.Length == 0 && reader.End) + break; + int length = ParseCount( + reader.ReadLine(), + $"{blockType} block length", + 16 * 1024 * 1024); + string payload = reader.ReadCharacters(length); + if (string.Equals( + blockType, + SalvageBlock, + StringComparison.Ordinal)) + { + profile.SalvageCombine = ReadSalvage(payload); + } + else + { + profile.UnknownBlocks.Add(new VtankLootExtraBlock + { + Type = blockType, + Payload = payload, + }); + } + } + return true; + } + catch (FormatException failure) + { + profile = new VtankLootProfile(); + error = failure.Message; + return false; + } + } + + public static string Write(VtankLootProfile profile) + { + ArgumentNullException.ThrowIfNull(profile); + var output = new StringBuilder(); + AppendLine(output, Header); + AppendLine(output, CurrentVersion); + AppendLine(output, profile.Rules.Count); + foreach (LootRule rule in profile.Rules) + WriteRule(output, rule); + + WriteBlock(output, SalvageBlock, WriteSalvage(profile.SalvageCombine)); + foreach (VtankLootExtraBlock block in profile.UnknownBlocks) + { + if (string.IsNullOrEmpty(block.Type) + || string.Equals( + block.Type, + SalvageBlock, + StringComparison.Ordinal)) + { + continue; + } + WriteBlock(output, block.Type, block.Payload ?? string.Empty); + } + return output.ToString(); + } + + private static LootRule ReadRule( + CharacterReader reader, + int version, + int ruleIndex) + { + string name = reader.ReadLine(); + string customExpression = version >= 1 + ? reader.ReadLine() + : string.Empty; + string[] fields = reader.ReadLine().Split(';'); + if (fields.Length < 2) + throw new FormatException($"Loot rule {ruleIndex + 1} has an invalid header."); + int priority = ParseInt(fields[0], $"rule {ruleIndex + 1} priority"); + int actionValue = ParseInt(fields[1], $"rule {ruleIndex + 1} action"); + if (actionValue is < 0 or > 10) + throw new FormatException($"Loot rule {ruleIndex + 1} has action {actionValue}."); + + var rule = new LootRule + { + Name = string.IsNullOrWhiteSpace(name) ? $"Rule {ruleIndex + 1}" : name, + Expression = "*", + CustomExpression = customExpression, + Action = (LootAction)actionValue, + Priority = priority, + }; + if (rule.Action == LootAction.KeepUpTo) + { + rule.KeepCount = Math.Max( + 0, + ParseInt(reader.ReadLine(), $"rule {ruleIndex + 1} keep count")); + } + + for (int field = 2; field < fields.Length; field++) + { + int type = ParseInt( + fields[field], + $"rule {ruleIndex + 1} requirement type"); + string payload; + if (version >= 1) + { + int length = ParseCount( + reader.ReadLine(), + $"rule {ruleIndex + 1} requirement length", + 16 * 1024 * 1024); + payload = reader.ReadCharacters(length); + } + else + { + int lines = LegacyPayloadLineCount(type); + if (lines < 0) + throw new FormatException( + $"Version 0 loot rule {ruleIndex + 1} uses unknown requirement {type}."); + var legacy = new StringBuilder(); + for (int line = 0; line < lines; line++) + AppendLine(legacy, reader.ReadLine()); + payload = legacy.ToString(); + } + rule.VtankRequirements.Add(new VtankLootRequirement + { + Type = type, + Payload = payload, + }); + } + return rule; + } + + private static void WriteRule(StringBuilder output, LootRule rule) + { + AppendLine(output, SingleLine(rule.Name, "Rule")); + AppendLine(output, SingleLine(rule.CustomExpression, string.Empty)); + + IReadOnlyList requirements = + ExportRequirements(rule); + var header = new StringBuilder(); + header.Append(rule.Priority.ToString(CultureInfo.InvariantCulture)); + header.Append(';'); + int action = (int)rule.Action is >= 0 and <= 10 + ? (int)rule.Action + : (int)LootAction.NoLoot; + header.Append(action.ToString(CultureInfo.InvariantCulture)); + foreach (VtankLootRequirement requirement in requirements) + { + header.Append(';'); + header.Append(requirement.Type.ToString(CultureInfo.InvariantCulture)); + } + AppendLine(output, header.ToString()); + + if (action == (int)LootAction.KeepUpTo) + AppendLine(output, Math.Max(0, rule.KeepCount)); + foreach (VtankLootRequirement requirement in requirements) + { + string payload = NormalizePayload(requirement.Payload); + AppendLine(output, payload.Length); + output.Append(payload); + } + } + + private static IReadOnlyList ExportRequirements( + LootRule rule) + { + if (rule.VtankRequirements.Count > 0) + return rule.VtankRequirements; + + // VTClassic stores CustomExpression for editors but its classifier does + // not execute it. An arbitrary MossTank expression therefore cannot be + // exported as an empty requirement set (which VTClassic treats as + // match-all); make the legacy copy visibly safe instead. + return + [ + new VtankLootRequirement + { + Type = DisabledRuleType, + Payload = "true" + NewLine, + }, + ]; + } + + private static VtankSalvageCombineSettings ReadSalvage(string payload) + { + var reader = new CharacterReader(payload); + _ = ParseInt(reader.ReadLine(), "salvage block version"); + var result = new VtankSalvageCombineSettings + { + DefaultCombineString = reader.ReadLine(), + MaterialCombineStrings = [], + MaterialValueModeValues = [], + }; + int strings = ParseCount( + reader.ReadLine(), + "salvage material rule count", + 10_000); + for (int index = 0; index < strings; index++) + { + int material = ParseInt(reader.ReadLine(), "salvage material id"); + result.MaterialCombineStrings[material] = reader.ReadLine(); + } + if (reader.End) + return result; + int values = ParseCount( + reader.ReadLine(), + "salvage value-mode count", + 10_000); + for (int index = 0; index < values; index++) + { + int material = ParseInt(reader.ReadLine(), "salvage value material id"); + result.MaterialValueModeValues[material] = ParseInt( + reader.ReadLine(), + "salvage value-mode value"); + } + return result; + } + + private static string WriteSalvage(VtankSalvageCombineSettings? settings) + { + settings ??= new VtankSalvageCombineSettings(); + var output = new StringBuilder(); + AppendLine(output, 1); + AppendLine(output, SingleLine( + settings.DefaultCombineString, + "1-6, 7-8, 9, 10")); + AppendLine(output, settings.MaterialCombineStrings.Count); + foreach ((int material, string combine) in + settings.MaterialCombineStrings.OrderBy(static pair => pair.Key)) + { + AppendLine(output, material); + AppendLine(output, SingleLine(combine, string.Empty)); + } + AppendLine(output, settings.MaterialValueModeValues.Count); + foreach ((int material, int value) in + settings.MaterialValueModeValues.OrderBy(static pair => pair.Key)) + { + AppendLine(output, material); + AppendLine(output, value); + } + return output.ToString(); + } + + private static void WriteBlock( + StringBuilder output, + string type, + string payload) + { + string normalized = NormalizePayload(payload); + AppendLine(output, SingleLine(type, "Unknown")); + AppendLine(output, normalized.Length); + output.Append(normalized); + } + + private static string NormalizePayload(string? payload) => + (payload ?? string.Empty) + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Replace("\n", NewLine, StringComparison.Ordinal); + + private static string SingleLine(string? value, string fallback) + { + string normalized = value ?? fallback; + int lineEnd = normalized.IndexOfAny(['\r', '\n']); + return lineEnd < 0 ? normalized : normalized[..lineEnd]; + } + + private static int LegacyPayloadLineCount(int type) => type switch + { + 0 => 1, + 1 => 2, + 2 or 3 or 4 or 5 or 11 or 12 or 13 or 2003 or 2005 => 2, + 6 or 7 or 8 or 10 or 1001 or 1002 or 1003 or 2000 or 2001 + or 2006 or 2007 or 9999 => 1, + 9 or 1004 or 2008 => 3, + 14 => 5, + 15 or 16 => 6, + 17 or 1000 => 2, + _ => -1, + }; + + private static int ParseCount(string value, string field, int maximum) + { + int parsed = ParseInt(value, field); + if (parsed < 0 || parsed > maximum) + throw new FormatException($"Invalid {field}: {value}."); + return parsed; + } + + private static int ParseInt(string value, string field) => + int.TryParse( + value, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out int parsed) + ? parsed + : throw new FormatException($"Invalid {field}: {value}."); + + private static void AppendLine(StringBuilder output, string value) => + output.Append(value).Append(NewLine); + + private static void AppendLine(StringBuilder output, int value) => + AppendLine(output, value.ToString(CultureInfo.InvariantCulture)); + + private sealed class CharacterReader(string source) + { + private int _position; + + public bool End => _position >= source.Length; + + public string ReadLine() + { + if (End) + throw new FormatException("The VTClassic loot profile ended unexpectedly."); + int start = _position; + while (_position < source.Length + && source[_position] is not ('\r' or '\n')) + { + _position++; + } + string line = source[start.._position]; + if (_position < source.Length && source[_position] == '\r') + _position++; + if (_position < source.Length && source[_position] == '\n') + _position++; + return line; + } + + public string ReadCharacters(int count) + { + if (count < 0 || count > source.Length - _position) + throw new FormatException("A VTClassic length-delimited block is truncated."); + string value = source.Substring(_position, count); + _position += count; + return value; + } + } +} diff --git a/src/AcDream.Plugins.MossTank/VtankLootRequirementEvaluator.cs b/src/AcDream.Plugins.MossTank/VtankLootRequirementEvaluator.cs new file mode 100644 index 00000000..f3fc0d32 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankLootRequirementEvaluator.cs @@ -0,0 +1,576 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// Executes VTClassic's typed loot requirements as an AND set. +internal static class VtankLootRequirementEvaluator +{ + private const uint VtankIntBase = 218_103_808u; + private const uint VtankDoubleBase = 167_772_160u; + + private static readonly IReadOnlyDictionary + IntSpellBonuses = new Dictionary + { + [2598] = (VtankIntBase + 34, 2), + [2586] = (VtankIntBase + 34, 4), + [4661] = (VtankIntBase + 34, 7), + [6089] = (VtankIntBase + 34, 10), + [2604] = (28, 20), + [2592] = (28, 40), + [4667] = (28, 60), + [6095] = (28, 80), + }; + + private static readonly IReadOnlyDictionary + DoubleSpellBonuses = new Dictionary + { + [3251] = (152, .01), [3250] = (152, .03), + [4670] = (152, .05), [6098] = (152, .07), + [2603] = (VtankDoubleBase + 12, .03), + [2591] = (VtankDoubleBase + 12, .05), + [4666] = (VtankDoubleBase + 12, .07), + [6094] = (VtankDoubleBase + 12, .09), + [2600] = (29, .03), [3985] = (29, .04), + [2588] = (29, .05), [4663] = (29, .07), [6091] = (29, .09), + [3201] = (144, 1.05), [3199] = (144, 1.10), + [3202] = (144, 1.15), [3200] = (144, 1.20), + [6086] = (144, 1.25), [6087] = (144, 1.30), + }; + + private static readonly IReadOnlyDictionary ArmorColorSlots = + new Dictionary(StringComparer.Ordinal) + { + ["Amuli Coat (Chest)"] = [0], + ["Amuli Coat (Collar/Shoulder)"] = [1, 2], + ["Amuli Coat (Arms/Trim)"] = [3, 4, 5, 6, 7], + ["Amuli Legs (Base)"] = [0, 1], + ["Amuli Legs (Trim)"] = [2, 3], + ["Celdon (Base)"] = [0], + ["Celdon (Veins)"] = [1, 2], + ["Chiran Coat (Base/Arms)"] = [0, 1], + ["Chiran Coat (Stripes)"] = [2, 3, 4], + ["Chiran Legs (Girth)"] = [1], + ["Chiran Legs (Legs)"] = [2, 3], + ["Chiran Legs (Trim)"] = [0], + ["Chiran Helm (Horns)"] = [0], + ["Chiran Helm (Base)"] = [1], + ["Haebrean BP (Chest) *"] = [0], + ["Haebrean BP (Ornaments)"] = [1], + ["Haebrean BP (Trim)"] = [2], + ["Haebrean Girth (Base) *"] = [0], + ["Haebrean Girth (Belt/Scales)"] = [1, 2], + ["Haebrean Helm (Base)"] = [0], + ["Haebrean Helm (Mask)"] = [1], + ["Haebrean Pauldrons (Base) *"] = [0], + ["Haebrean Pauldrons (Ornaments)"] = [1], + ["Lorica BP (Veins)"] = [0, 1], + ["Lorica BP (Base)"] = [2, 3], + ["Lorica BP (Neck/Trim) *"] = [4], + ["Lorica Legs (Base)"] = [0], + ["Lorica Legs (Knees/Belt/Crotch) *"] = [1, 2], + ["Lorica Legs (Legs) *"] = [3], + ["Nariyid BP (Circle/Lines)"] = [0, 1], + ["Nariyid BP (Base)"] = [2], + ["Nariyid BP (Shoulders)"] = [3], + ["Nariyid Girth (Base) *"] = [0], + ["Nariyid Girth (Belt/Lines)"] = [2], + ["Nariyid Girth (Ornaments)"] = [3], + ["Nariyid Sleeves (Shoulders)"] = [0], + ["Nariyid Sleeves (Upper Arm)"] = [1, 2], + ["Nariyid Sleeves (Lower Arm)"] = [3], + ["Olthoi BP (Base)"] = [0], + ["Olthoi BP (Veins)"] = [1], + ["Olthoi Alduressa Legs (Girth: Base)"] = [0, 1, 2], + ["Olthoi Alduressa Legs (Girth: Lines)"] = [3], + ["Olthoi Alduressa Legs (Legs: Lines)"] = [4, 5], + ["Olthoi Amuli Coat (Base) *"] = [0, 1], + ["Olthoi Amuli Coat (Trim)"] = [2], + ["Olthoi Amuli Coat (Shoulders)"] = [3], + ["Olthoi Amuli Legs (Trim)"] = [6, 7, 8], + ["Olthoi Koujia Kabuton (Base)"] = [0], + ["Olthoi Koujia Kabuton (Horns)"] = [1], + ["Olthoi Koujia Legs (Base)"] = [0, 1, 2], + ["Olthoi Koujia Legs (Sides/Shins)"] = [3, 4, 5], + ["Scalemail Cuirass (Base)"] = [0], + ["Scalemail Cuirass (Bumps)"] = [1], + ["Scalemail Cuirass (Belt)"] = [2], + ["Tenassa Legs (Line at Side)"] = [0], + ["Tenassa Legs (Base)"] = [1], + ["Tenassa Legs (Hilight)"] = [2], + ["Tenassa BP (Shoulders)"] = [0], + ["Tenassa BP (Base)"] = [1], + ["Yoroi Cuirass (Base)"] = [0, 1], + ["Yoroi Cuirass (Belt)"] = [2], + ["Yoroi Girth (Base)"] = [0], + ["Yoroi Girth (Belt)"] = [1], + }; + + public static bool IsMatch( + IReadOnlyList requirements, + in PluginInventoryItem item, + in PluginItemProperties properties, + IPluginHost? host, + out string? error) + { + try + { + foreach (VtankLootRequirement requirement in requirements) + { + if (!IsMatch(requirement, item, properties, host)) + { + error = null; + return false; + } + } + error = null; + return true; + } + catch (Exception failure) when ( + failure is FormatException or ArgumentException or OverflowException) + { + error = failure.Message; + return false; + } + } + + private static bool IsMatch( + VtankLootRequirement requirement, + in PluginInventoryItem item, + in PluginItemProperties properties, + IPluginHost? host) + { + string[] values = Lines(requirement.Payload); + return requirement.Type switch + { + 0 => SpellNames(item, host).Any(name => Rx(values, 0).IsMatch(name)), + 1 => Rx(values, 0).IsMatch(StringValue( + U32(values, 1), item, properties)), + 2 => IntValue(U32(values, 1), item, properties) <= I32(values, 0), + 3 => IntValue(U32(values, 1), item, properties) >= I32(values, 0), + 4 => (float)DoubleValue(U32(values, 1), item, properties) + <= (float)F64(values, 0), + 5 => (float)DoubleValue(U32(values, 1), item, properties) + >= (float)F64(values, 0), + // VTClassic deliberately retired this requirement; its own Match + // method always returns false. + 6 => false, + 7 => (int)item.ObjectClass == I32(values, 0), + 8 => item.AppraisedSpellIds.Count >= I32(values, 0), + 9 => SpellMatch(values, item, host), + 10 => MinimumDamage(item) >= F64(values, 0), + 11 => (IntValue(U32(values, 1), item, properties) + & I32(values, 0)) > 0, + 12 => IntValue(U32(values, 1), item, properties) == I32(values, 0), + 13 => IntValue(U32(values, 1), item, properties) != I32(values, 0), + 14 => ColorMatch(values, item.Palettes), + 15 => ArmorColorMatch(values, item.Palettes), + 16 => SlotColorMatch(values, item.Palettes), + 17 => ExactPalette(values, item.Palettes), + 1000 => CharacterSkill(host, U32(values, 1), buffed: true) + >= I32(values, 0), + 1001 => (host?.Automation.Character.MainPackFreeSlots ?? 0) + >= I32(values, 0), + 1002 => (host?.Automation.Character.Level ?? 0) >= I32(values, 0), + 1003 => (host?.Automation.Character.Level ?? 0) <= I32(values, 0), + 1004 => CharacterBaseSkillRange(values, host), + 2000 => BuffedMedianDamage(item, properties) >= F64(values, 0), + 2001 => BuffedMissileDamage(item, properties) >= F64(values, 0), + 2003 => BuffedInt( + U32(values, 1), item, properties) >= F64(values, 0), + 2005 => (float)BuffedDouble( + U32(values, 1), item, properties) >= (float)F64(values, 0), + 2006 => BuffedTinkedDamage(item, properties) >= F64(values, 0), + 2007 => TotalRatings(item, properties) >= F64(values, 0), + 2008 => CanReachTarget(values, item, properties), + 9999 => !Bool(values, 0), + _ => false, + }; + } + + private static bool SpellMatch( + string[] values, + in PluginInventoryItem item, + IPluginHost? host) + { + Regex include = Rx(values, 0); + Regex exclude = Rx(values, 1); + bool excludeEmpty = Value(values, 1).Trim().Length == 0; + int required = I32(values, 2); + int count = 0; + foreach (string name in SpellNames(item, host)) + { + if (include.IsMatch(name) + && (excludeEmpty || !exclude.IsMatch(name)) + && ++count >= required) + { + return true; + } + } + return false; + } + + private static bool ColorMatch( + string[] values, + IReadOnlyList palettes) + { + for (int index = 0; index < palettes.Count; index++) + { + if (SimilarColor(values, palettes[index])) + return true; + } + return false; + } + + private static bool ArmorColorMatch( + string[] values, + IReadOnlyList palettes) + { + if (!ArmorColorSlots.TryGetValue(Value(values, 5), out int[]? slots)) + return false; + foreach (int slot in slots) + { + if (slot >= 0 && slot < palettes.Count + && SimilarColor(values, palettes[slot])) + { + return true; + } + } + return false; + } + + private static bool SlotColorMatch( + string[] values, + IReadOnlyList palettes) + { + int slot = I32(values, 5); + return slot >= 0 && slot < palettes.Count + && SimilarColor(values, palettes[slot]); + } + + private static bool ExactPalette( + string[] values, + IReadOnlyList palettes) + { + int slot = I32(values, 0); + uint expected = U32(values, 1) & 0x00FF_FFFFu; + return slot >= 0 && slot < palettes.Count + && (palettes[slot].PaletteId & 0x00FF_FFFFu) == expected; + } + + private static bool SimilarColor( + string[] values, + in PluginPaletteInfo palette) + { + Hsv( + checked((byte)I32(values, 0)), + checked((byte)I32(values, 1)), + checked((byte)I32(values, 2)), + out double targetHue, + out double targetSaturation, + out double targetValue); + Hsv( + palette.Red, + palette.Green, + palette.Blue, + out double hue, + out double saturation, + out double value); + if (Math.Abs(hue - targetHue) > F64(values, 3)) + return false; + double sd = saturation - targetSaturation; + double vd = value - targetValue; + return Math.Sqrt((sd * sd) + (vd * vd)) <= F64(values, 4); + } + + private static void Hsv( + byte red, + byte green, + byte blue, + out double hue, + out double saturation, + out double value) + { + int maximum = Math.Max(red, Math.Max(green, blue)); + int minimum = Math.Min(red, Math.Min(green, blue)); + int delta = maximum - minimum; + if (delta == 0) + { + hue = 0d; + } + else if (maximum == red) + { + hue = 60d * (green - blue) / delta; + if (hue < 0d) + hue += 360d; + } + else if (maximum == green) + { + hue = (60d * (blue - red) / delta) + 120d; + } + else + { + hue = (60d * (red - green) / delta) + 240d; + } + saturation = maximum == 0 ? 0d : 1d - ((double)minimum / maximum); + value = maximum / 255d; + } + + private static IEnumerable SpellNames( + PluginInventoryItem item, + IPluginHost? host) + { + if (host is null) + yield break; + foreach (uint spellId in item.AppraisedSpellIds) + { + if (host.Automation.Spells.TryGet(spellId, out PluginSpellInfo spell)) + yield return spell.Name; + } + } + + private static int CharacterSkill( + IPluginHost? host, + uint skillId, + bool buffed) + { + if (host?.Automation.Character.TryGetSkill( + skillId, + out PluginSkillInfo skill) != true) + { + return 0; + } + return checked((int)(buffed ? skill.Current : skill.Base)); + } + + private static bool CharacterBaseSkillRange( + string[] values, + IPluginHost? host) + { + int level = CharacterSkill(host, U32(values, 0), buffed: false); + return level >= I32(values, 1) && level <= I32(values, 2); + } + + private static string StringValue( + uint key, + in PluginInventoryItem item, + in PluginItemProperties properties) => key switch + { + 1 => item.Name, + _ => properties.Strings?.TryGetValue(key, out string? value) == true + ? value + : string.Empty, + }; + + private static int IntValue( + uint key, + in PluginInventoryItem item, + in PluginItemProperties properties) => key switch + { + 5 => item.Burden, + 19 => item.Value, + 105 => checked((int)item.Workmanship), + 107 => item.ItemCurrentMana, + 108 => item.ItemMaximumMana, + 131 => checked((int)item.MaterialType), + VtankIntBase + 0 => checked((int)item.WeenieClassId), + VtankIntBase + 2 => checked((int)item.ContainerObjectId), + VtankIntBase + 4 => item.ItemsCapacity, + VtankIntBase + 5 => item.ContainersCapacity, + VtankIntBase + 6 => item.StackSize, + VtankIntBase + 7 => item.MaximumStackSize, + VtankIntBase + 8 => checked((int)item.SpellId), + VtankIntBase + 9 => item.ContainerSlot, + VtankIntBase + 10 => checked((int)item.WielderObjectId), + VtankIntBase + 11 => checked((int)item.EquippedLocation), + VtankIntBase + 14 => checked((int)item.ValidLocations), + VtankIntBase + 18 => checked((int)item.Useability), + VtankIntBase + 23 => checked((int)item.PublicFlags), + VtankIntBase + 31 => item.CombatUse, + VtankIntBase + 32 => item.WeaponSkill, + VtankIntBase + 33 => item.DamageType, + VtankIntBase + 34 => item.Damage, + VtankIntBase + 38 => item.AppraisedSpellIds.Count, + _ => properties.Ints?.TryGetValue(key, out int value) == true + ? value + : 0, + }; + + private static double DoubleValue( + uint key, + in PluginInventoryItem item, + in PluginItemProperties properties) => key switch + { + VtankDoubleBase + 9 => item.Workmanship, + VtankDoubleBase + 11 => item.DamageVariance, + VtankDoubleBase + 12 => RawFloat(properties, 62), + VtankDoubleBase + 14 => RawFloat(properties, 63), + _ => RawFloat(properties, key), + }; + + private static double RawFloat(in PluginItemProperties properties, uint key) => + properties.Floats?.TryGetValue(key, out double value) == true ? value : 0d; + + private static int BuffedInt( + uint key, + in PluginInventoryItem item, + in PluginItemProperties properties) + { + int value = IntValue(key, item, properties); + foreach (uint spellId in item.AppraisedSpellIds) + { + if (IntSpellBonuses.TryGetValue(spellId, out var bonus) + && bonus.Key == key) + { + value += bonus.Bonus; + } + } + return value; + } + + private static double BuffedDouble( + uint key, + in PluginInventoryItem item, + in PluginItemProperties properties) + { + double value = DoubleValue(key, item, properties); + foreach (uint spellId in item.AppraisedSpellIds) + { + if (!DoubleSpellBonuses.TryGetValue(spellId, out var bonus) + || bonus.Key != key) + { + continue; + } + value = (int)bonus.Bonus == 1 ? value * bonus.Bonus : value + bonus.Bonus; + } + return value; + } + + private static double MinimumDamage(in PluginInventoryItem item) => + item.Damage - (item.DamageVariance * item.Damage); + + private static double BuffedMedianDamage( + in PluginInventoryItem item, + in PluginItemProperties properties) + { + int maximum = BuffedInt(VtankIntBase + 34, item, properties); + double minimum = maximum - (item.DamageVariance * maximum); + return (minimum + maximum) / 2d; + } + + private static double BuffedMissileDamage( + in PluginInventoryItem item, + in PluginItemProperties properties) => + BuffedInt(VtankIntBase + 34, item, properties) + + (((BuffedDouble(VtankDoubleBase + 14, item, properties) - 1d) + * 100d) / 3d) + + BuffedInt(204, item, properties); + + private static double BuffedTinkedDamage( + in PluginInventoryItem item, + in PluginItemProperties properties) + { + double variance = item.DamageVariance; + int maximum = BuffedInt(VtankIntBase + 34, item, properties); + int tinks = Math.Max(10 - IntValue(171, item, properties), 0); + if (IntValue(179, item, properties) == 0) + tinks--; + if (IntValue(131, item, properties) == 0) + tinks = 0; + for (int index = 1; index <= tinks; index++) + { + double iron = DamageOverTime(maximum + 25, variance); + double granite = DamageOverTime(maximum + 24, variance * .8d); + if (iron >= granite) + maximum++; + else + variance *= .8d; + } + return DamageOverTime(maximum + 24, variance); + } + + private static int TotalRatings( + in PluginInventoryItem item, + in PluginItemProperties properties) => + item.GearDamage + item.GearDamageResistance + + item.GearCriticalChance + item.GearCriticalResistance + + item.GearCriticalDamage + item.GearCriticalDamageResistance + + IntValue(376, item, properties) + IntValue(379, item, properties); + + private static bool CanReachTarget( + string[] values, + in PluginInventoryItem item, + in PluginItemProperties properties) + { + double targetDamage = F64(values, 0); + double targetDefense = F64(values, 1); + double targetAttack = F64(values, 2); + double defense = BuffedDouble(29, item, properties); + double attack = BuffedDouble(VtankDoubleBase + 12, item, properties); + double variance = item.DamageVariance; + int maximum = BuffedInt(VtankIntBase + 34, item, properties); + int tinks = Math.Max(10 - IntValue(171, item, properties), 0); + if (IntValue(179, item, properties) == 0) + tinks--; + if (IntValue(131, item, properties) == 0) + tinks = 0; + for (int index = 1; index <= tinks; index++) + { + if (defense < targetDefense) + defense += .01d; + else if (attack < targetAttack) + attack += .01d; + else if (DamageOverTime(maximum + 25, variance) + >= DamageOverTime(maximum + 24, variance * .8d)) + maximum++; + else + variance *= .8d; + } + return DamageOverTime(maximum + 24, variance) >= targetDamage + && defense >= targetDefense + && attack >= targetAttack; + } + + private static double DamageOverTime(int maximum, double variance) => + maximum * ((.9d * (2d - variance) / 2d) + .2d); + + private static string[] Lines(string? payload) => + (payload ?? string.Empty) + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Split('\n'); + + private static string Value(string[] values, int index) => + index >= 0 && index < values.Length + ? values[index] + : throw new FormatException("A VTClassic loot requirement is truncated."); + + private static Regex Rx(string[] values, int index) => new( + Value(values, index), + RegexOptions.CultureInvariant, + TimeSpan.FromMilliseconds(100)); + + private static int I32(string[] values, int index) => + int.TryParse(Value(values, index), NumberStyles.Integer, + CultureInfo.InvariantCulture, out int parsed) + ? parsed + : throw new FormatException("A VTClassic integer is invalid."); + + private static uint U32(string[] values, int index) => + uint.TryParse(Value(values, index), NumberStyles.Integer, + CultureInfo.InvariantCulture, out uint parsed) + ? parsed + : throw new FormatException("A VTClassic key is invalid."); + + private static double F64(string[] values, int index) => + double.TryParse(Value(values, index).Replace(',', '.'), + NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) + ? parsed + : throw new FormatException("A VTClassic number is invalid."); + + private static bool Bool(string[] values, int index) => + bool.TryParse(Value(values, index), out bool parsed) + ? parsed + : throw new FormatException("A VTClassic boolean is invalid."); +} diff --git a/src/AcDream.Plugins.MossTank/VtankMetaProfileSerializer.cs b/src/AcDream.Plugins.MossTank/VtankMetaProfileSerializer.cs new file mode 100644 index 00000000..faa3a5bb --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankMetaProfileSerializer.cs @@ -0,0 +1,742 @@ +using System.Globalization; + +namespace AcDream.Plugins.MossTank; + +/// +/// Reads and writes VTank's exact line-encoded CondAct Meta database. +/// The format is the public interchange contract used by legacy .met +/// profiles; it is deliberately independent from MossTank's native JSON store. +/// +internal static class VtankMetaProfileSerializer +{ + private static readonly string[] Header = + [ + "1", "CondAct", "5", "CType", "AType", "CData", "AData", + "State", "n", "n", "n", "n", "n", + ]; + + private static readonly string[] TablePrefix = ["TABLE", "2", "k", "v", "n", "n"]; + private static readonly string[] RecursiveTablePrefix = ["TABLE", "2", "K", "V", "n", "n"]; + private const int MaximumRules = 100_000; + private const int MaximumNesting = 256; + + public static bool TryLoad(string source, out MetaProfile profile, out string error) + { + try + { + var reader = new LineReader(source); + reader.Expect(Header); + int count = reader.ReadInt(); + if (count is < 0 or > MaximumRules) + throw reader.Error("Invalid VTank Meta rule count."); + + var parsed = new MetaProfile(); + for (int index = 0; index < count; index++) + { + reader.Expect("i"); + int conditionType = reader.ReadInt(); + reader.Expect("i"); + int actionType = reader.ReadInt(); + MetaCondition condition = ReadCondition(reader, conditionType, 0); + MetaAction action = ReadAction(reader, actionType, 0); + reader.Expect("s"); + parsed.Rules.Add(new MetaRule + { + State = reader.Read(), + Condition = condition, + Action = action, + Enabled = true, + }); + } + reader.ExpectEnd(); + profile = parsed; + error = string.Empty; + return true; + } + catch (Exception exception) when (exception is FormatException + or OverflowException or ArgumentOutOfRangeException) + { + profile = new MetaProfile(); + error = exception.Message; + return false; + } + } + + public static string Save(MetaProfile source) + { + ArgumentNullException.ThrowIfNull(source); + var writer = new LineWriter(); + writer.Add(Header); + MetaRule[] rules = source.Rules.Where(static rule => rule.Enabled).ToArray(); + writer.Add(rules.Length); + foreach (MetaRule rule in rules) + { + writer.Add("i", ConditionType(rule.Condition.Kind), "i", ActionType(rule.Action.Kind)); + WriteCondition(writer, rule.Condition, 0); + WriteAction(writer, rule.Action, 0); + writer.Add("s", rule.State ?? string.Empty); + } + return writer.Finish(); + } + + private static MetaCondition ReadCondition(LineReader reader, int type, int depth) + { + CheckDepth(reader, depth); + var value = new MetaCondition { Kind = ConditionKind(type) }; + switch (type) + { + case 0 or 1 or 7 or 8 or 9 or 10 or 15 or 19 or 20: + reader.Expect("i", "0"); + break; + case 2 or 3: + reader.Expect(RecursiveTablePrefix); + ReadConditions(reader, value, reader.ReadCount(), depth); + break; + case 4: + reader.Expect("s"); + value.Text = reader.Read(); + break; + case 5 or 6 or 17 or 18 or 22 or 24: + reader.Expect("i"); + value.Number = reader.ReadInt(); + break; + case 11 or 12: + reader.Expect(TablePrefix, "2", "s", "n", "s"); + value.Text = reader.Read(); + reader.Expect("s", "c", "i"); + value.Number = reader.ReadInt(); + break; + case 13: + reader.Expect(TablePrefix, "3", "s", "n", "s"); + value.Text = reader.Read(); + reader.Expect("s", "c", "i"); + value.Number = reader.ReadInt(); + reader.Expect("s", "r", "d"); + value.SecondaryNumber = reader.ReadDouble(); + break; + case 14: + reader.Expect(TablePrefix, "3", "s", "p", "i"); + value.TertiaryNumber = reader.ReadInt(); + reader.Expect("s", "c", "i"); + value.Number = reader.ReadInt(); + reader.Expect("s", "r", "d"); + value.SecondaryNumber = reader.ReadDouble(); + break; + case 16: + reader.Expect(TablePrefix, "1", "s", "r", "d"); + value.Number = reader.ReadDouble(); + break; + case 21: + reader.Expect(RecursiveTablePrefix); + if (reader.ReadCount() != 1) + throw reader.Error("VTank Meta Not requires exactly one condition."); + reader.Expect("i"); + value.Children.Add(ReadCondition(reader, reader.ReadInt(), depth + 1)); + break; + case 23: + reader.Expect(TablePrefix, "2", "s", "sid", "i"); + value.Number = reader.ReadInt(); + reader.Expect("s", "sec", "i"); + value.SecondaryNumber = reader.ReadInt(); + break; + case 25: + reader.Expect(TablePrefix, "1", "s", "dist", "d"); + value.Number = reader.ReadDouble(); + break; + case 26: + reader.Expect(TablePrefix, "1", "s", "e", "s"); + value.Text = reader.Read(); + break; + case 28: + reader.Expect(TablePrefix, "2", "s", "p", "s"); + value.Text = reader.Read(); + reader.Expect("s", "c", "s"); + value.SecondaryText = reader.Read(); + break; + default: + throw reader.Error($"Unknown VTank Meta condition type {type}."); + } + return value; + } + + private static void ReadConditions( + LineReader reader, + MetaCondition target, + int count, + int depth) + { + for (int index = 0; index < count; index++) + { + reader.Expect("i"); + target.Children.Add(ReadCondition(reader, reader.ReadInt(), depth + 1)); + } + } + + private static MetaAction ReadAction(LineReader reader, int type, int depth) + { + CheckDepth(reader, depth); + var value = new MetaAction { Kind = ActionKind(type) }; + switch (type) + { + case 0 or 6: + reader.Expect("i", "0"); + break; + case 1 or 2: + reader.Expect("s"); + value.Text = reader.Read(); + break; + case 3: + reader.Expect(RecursiveTablePrefix); + int count = reader.ReadCount(); + for (int index = 0; index < count; index++) + { + reader.Expect("i"); + value.Children.Add(ReadAction(reader, reader.ReadInt(), depth + 1)); + } + break; + case 4: + ReadEmbeddedNavigation(reader, value); + break; + case 5: + reader.Expect(TablePrefix, "2", "s", "st", "s"); + value.Text = reader.Read(); + reader.Expect("s", "ret", "s"); + value.SecondaryText = reader.Read(); + break; + case 7 or 8: + reader.Expect(TablePrefix, "1", "s", "e", "s"); + value.Text = reader.Read(); + break; + case 9: + reader.Expect(TablePrefix, "3", "s", "s", "s"); + value.Text = reader.Read(); + reader.Expect("s", "r", "d"); + value.Number = reader.ReadDouble(); + reader.Expect("s", "t", "d"); + value.SecondaryNumber = reader.ReadDouble(); + break; + case 10 or 15: + reader.Expect(TablePrefix, "0"); + break; + case 11: + reader.Expect(TablePrefix, "2", "s", "o", "s"); + value.Text = reader.Read(); + reader.Expect("s", "v", "s"); + value.SecondaryText = reader.Read(); + break; + case 12: + reader.Expect(TablePrefix, "2", "s", "o", "s"); + value.Text = reader.Read(); + reader.Expect("s", "v", "s"); + value.SecondaryText = reader.Read(); + break; + case 13: + reader.Expect(TablePrefix, "2", "s", "n", "s"); + value.Text = reader.Read(); + reader.Expect("s", "x", "ba"); + int length = reader.ReadCount(); + value.SecondaryText = reader.ReadByteArray(length); + break; + case 14: + reader.Expect(TablePrefix, "1", "s", "n", "s"); + value.Text = reader.Read(); + break; + default: + throw reader.Error($"Unknown VTank Meta action type {type}."); + } + return value; + } + + private static void ReadEmbeddedNavigation(LineReader reader, MetaAction target) + { + reader.Expect("ba"); + int serializedCharacters = reader.ReadCount(); + target.SecondaryText = reader.Read(); + int statedNodeCount = reader.ReadCount(); + if (serializedCharacters <= 5) + { + target.Text = EmptyNavigation(); + return; + } + + var lines = new List { reader.ReadExpected("uTank2 NAV 1.2") }; + int mode = reader.ReadInt(out string modeLine); + lines.Add(modeLine); + int actualNodeCount; + if (mode == 3) + { + lines.Add(reader.Read()); + lines.Add(reader.Read()); + actualNodeCount = 1; + } + else if (mode is 1 or 2 or 4) + { + actualNodeCount = reader.ReadCount(out string countLine); + lines.Add(countLine); + for (int index = 0; index < actualNodeCount; index++) + ReadNavigationNode(reader, lines); + } + else + { + throw reader.Error($"Unknown embedded VTank navigation type {mode}."); + } + if (actualNodeCount != statedNodeCount) + throw reader.Error("Embedded VTank navigation node counts do not match."); + target.Text = string.Join("\r\n", lines) + "\r\n"; + } + + private static void ReadNavigationNode(LineReader reader, List lines) + { + int type = reader.ReadInt(out string typeLine); + lines.Add(typeLine); + for (int index = 0; index < 4; index++) + lines.Add(reader.Read()); + int extra = type switch + { + 0 or 8 => 0, + 1 or 2 or 3 or 4 => 1, + 5 => 2, + 6 or 7 => 6, + 9 => 3, + _ => throw reader.Error($"Unknown embedded VTank waypoint type {type}."), + }; + for (int index = 0; index < extra; index++) + lines.Add(reader.Read()); + } + + private static void WriteCondition(LineWriter writer, MetaCondition value, int depth) + { + CheckDepth(depth); + int type = ConditionType(value.Kind); + switch (type) + { + case 0 or 1 or 7 or 8 or 9 or 10 or 15 or 19 or 20: + writer.Add("i", "0"); + break; + case 2 or 3: + writer.Add(RecursiveTablePrefix); + writer.Add(value.Children.Count); + foreach (MetaCondition child in value.Children) + { + writer.Add("i", ConditionType(child.Kind)); + WriteCondition(writer, child, depth + 1); + } + break; + case 4: + writer.Add("s", value.Text); + break; + case 5 or 6 or 17 or 18 or 22 or 24: + writer.Add("i", IntValue(value.Number)); + break; + case 11 or 12: + writer.Add(TablePrefix, "2", "s", "n", "s", value.Text, + "s", "c", "i", IntValue(value.Number)); + break; + case 13: + writer.Add(TablePrefix, "3", "s", "n", "s", value.Text, + "s", "c", "i", IntValue(value.Number), + "s", "r", "d", Number(value.SecondaryNumber)); + break; + case 14: + writer.Add(TablePrefix, "3", "s", "p", "i", IntValue(value.TertiaryNumber), + "s", "c", "i", IntValue(value.Number), + "s", "r", "d", Number(value.SecondaryNumber)); + break; + case 16: + writer.Add(TablePrefix, "1", "s", "r", "d", Number(value.Number)); + break; + case 21: + if (value.Children.Count != 1) + throw new InvalidOperationException("VTank Meta Not requires exactly one condition."); + writer.Add(RecursiveTablePrefix, "1", "i", ConditionType(value.Children[0].Kind)); + WriteCondition(writer, value.Children[0], depth + 1); + break; + case 23: + writer.Add(TablePrefix, "2", "s", "sid", "i", IntValue(value.Number), + "s", "sec", "i", IntValue(value.SecondaryNumber)); + break; + case 25: + writer.Add(TablePrefix, "1", "s", "dist", "d", Number(value.Number)); + break; + case 26: + writer.Add(TablePrefix, "1", "s", "e", "s", value.Text); + break; + case 28: + writer.Add(TablePrefix, "2", "s", "p", "s", value.Text, + "s", "c", "s", value.SecondaryText); + break; + default: + throw new InvalidOperationException($"Unknown VTank Meta condition type {type}."); + } + } + + private static void WriteAction(LineWriter writer, MetaAction value, int depth) + { + CheckDepth(depth); + int type = ActionType(value.Kind); + switch (type) + { + case 0 or 6: + writer.Add("i", "0"); + break; + case 1 or 2: + writer.Add("s", value.Text); + break; + case 3: + writer.Add(RecursiveTablePrefix); + writer.Add(value.Children.Count); + foreach (MetaAction child in value.Children) + { + writer.Add("i", ActionType(child.Kind)); + WriteAction(writer, child, depth + 1); + } + break; + case 4: + WriteEmbeddedNavigation(writer, value); + break; + case 5: + writer.Add(TablePrefix, "2", "s", "st", "s", value.Text, + "s", "ret", "s", value.SecondaryText); + break; + case 7 or 8: + writer.Add(TablePrefix, "1", "s", "e", "s", value.Text); + break; + case 9: + writer.Add(TablePrefix, "3", "s", "s", "s", value.Text, + "s", "r", "d", Number(value.Number), + "s", "t", "d", Number(value.SecondaryNumber)); + break; + case 10 or 15: + writer.Add(TablePrefix, "0"); + break; + case 11 or 12: + writer.Add(TablePrefix, "2", "s", "o", "s", value.Text, + "s", "v", "s", value.SecondaryText); + break; + case 13: + writer.Add(TablePrefix, "2", "s", "n", "s", value.Text, + "s", "x", "ba", value.SecondaryText.Length); + writer.AddBuggedByteArray(value.SecondaryText); + break; + case 14: + writer.Add(TablePrefix, "1", "s", "n", "s", value.Text); + break; + default: + throw new InvalidOperationException($"Unknown VTank Meta action type {type}."); + } + } + + private static void WriteEmbeddedNavigation(LineWriter writer, MetaAction value) + { + string nav = string.IsNullOrWhiteSpace(value.Text) ? EmptyNavigation() : value.Text; + string normalized = NormalizeNewlines(nav); + string[] navLines = normalized.Split('\n', StringSplitOptions.None); + if (navLines.Length != 0 && navLines[^1].Length == 0) + navLines = navLines[..^1]; + int nodes = NavigationNodeCount(navLines); + string name = string.IsNullOrEmpty(value.SecondaryText) ? "[None]" : value.SecondaryText; + int characters = name.Length + 2 + + nodes.ToString(CultureInfo.InvariantCulture).Length + 2 + + navLines.Sum(static line => line.Length + 2); + writer.Add("ba", characters, name, nodes); + writer.Add(navLines); + } + + private static int NavigationNodeCount(string[] lines) + { + if (lines.Length < 2 || !lines[0].Equals("uTank2 NAV 1.2", StringComparison.Ordinal)) + throw new InvalidOperationException("Embedded Meta route is not uTank2 NAV 1.2 data."); + int mode = int.Parse(lines[1], NumberStyles.Integer, CultureInfo.InvariantCulture); + if (mode == 3) + return 1; + if (mode is not (1 or 2 or 4) || lines.Length < 3) + throw new InvalidOperationException("Embedded Meta route has an invalid navigation type."); + return int.Parse(lines[2], NumberStyles.Integer, CultureInfo.InvariantCulture); + } + + private static string EmptyNavigation() => "uTank2 NAV 1.2\r\n1\r\n0\r\n"; + + private static string NormalizeNewlines(string value) => value + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n'); + + private static int ConditionType(MetaConditionKind kind) => kind switch + { + MetaConditionKind.Never => 0, + MetaConditionKind.Always => 1, + MetaConditionKind.All => 2, + MetaConditionKind.Any => 3, + MetaConditionKind.ChatMessage => 4, + MetaConditionKind.PackSlotsLessThanOrEqual => 5, + MetaConditionKind.SecondsInStateGreaterThanOrEqual => 6, + MetaConditionKind.NavigationRouteEmpty => 7, + MetaConditionKind.CharacterDeath => 8, + MetaConditionKind.AnyVendorOpen => 9, + MetaConditionKind.VendorClosed => 10, + MetaConditionKind.InventoryItemCountLessThanOrEqual => 11, + MetaConditionKind.InventoryItemCountGreaterThanOrEqual => 12, + MetaConditionKind.MonsterNameCountWithinDistance => 13, + MetaConditionKind.MonsterPriorityCountWithinDistance => 14, + MetaConditionKind.NeedToBuff => 15, + MetaConditionKind.NoMonstersWithinDistance => 16, + MetaConditionKind.LandblockEquals => 17, + MetaConditionKind.LandcellEquals => 18, + MetaConditionKind.PortalspaceEntered => 19, + MetaConditionKind.PortalspaceExited => 20, + MetaConditionKind.Not => 21, + MetaConditionKind.PersistentSecondsInStateGreaterThanOrEqual => 22, + MetaConditionKind.TimeLeftOnSpellGreaterThanOrEqual => 23, + MetaConditionKind.BurdenPercentGreaterThanOrEqual => 24, + MetaConditionKind.DistanceFromAnyRoutePointGreaterThanOrEqual => 25, + MetaConditionKind.Expression => 26, + MetaConditionKind.ChatMessageCapture => 28, + _ => throw new InvalidOperationException($"Unsupported Meta condition {kind}."), + }; + + private static MetaConditionKind ConditionKind(int type) => type switch + { + 0 => MetaConditionKind.Never, + 1 => MetaConditionKind.Always, + 2 => MetaConditionKind.All, + 3 => MetaConditionKind.Any, + 4 => MetaConditionKind.ChatMessage, + 5 => MetaConditionKind.PackSlotsLessThanOrEqual, + 6 => MetaConditionKind.SecondsInStateGreaterThanOrEqual, + 7 => MetaConditionKind.NavigationRouteEmpty, + 8 => MetaConditionKind.CharacterDeath, + 9 => MetaConditionKind.AnyVendorOpen, + 10 => MetaConditionKind.VendorClosed, + 11 => MetaConditionKind.InventoryItemCountLessThanOrEqual, + 12 => MetaConditionKind.InventoryItemCountGreaterThanOrEqual, + 13 => MetaConditionKind.MonsterNameCountWithinDistance, + 14 => MetaConditionKind.MonsterPriorityCountWithinDistance, + 15 => MetaConditionKind.NeedToBuff, + 16 => MetaConditionKind.NoMonstersWithinDistance, + 17 => MetaConditionKind.LandblockEquals, + 18 => MetaConditionKind.LandcellEquals, + 19 => MetaConditionKind.PortalspaceEntered, + 20 => MetaConditionKind.PortalspaceExited, + 21 => MetaConditionKind.Not, + 22 => MetaConditionKind.PersistentSecondsInStateGreaterThanOrEqual, + 23 => MetaConditionKind.TimeLeftOnSpellGreaterThanOrEqual, + 24 => MetaConditionKind.BurdenPercentGreaterThanOrEqual, + 25 => MetaConditionKind.DistanceFromAnyRoutePointGreaterThanOrEqual, + 26 => MetaConditionKind.Expression, + 28 => MetaConditionKind.ChatMessageCapture, + _ => throw new FormatException($"Unknown VTank Meta condition type {type}."), + }; + + private static int ActionType(MetaActionKind kind) => kind switch + { + MetaActionKind.None => 0, + MetaActionKind.SetMetaState => 1, + MetaActionKind.ChatCommand => 2, + MetaActionKind.All => 3, + MetaActionKind.LoadEmbeddedNavigationRoute => 4, + MetaActionKind.CallMetaState => 5, + MetaActionKind.ReturnFromCall => 6, + MetaActionKind.ExpressionAction => 7, + MetaActionKind.ChatExpression => 8, + MetaActionKind.SetWatchdog => 9, + MetaActionKind.ClearWatchdog => 10, + MetaActionKind.GetVtankOption => 11, + MetaActionKind.SetVtankOption => 12, + MetaActionKind.CreateView => 13, + MetaActionKind.DestroyView => 14, + MetaActionKind.DestroyAllViews => 15, + _ => throw new InvalidOperationException($"Unsupported Meta action {kind}."), + }; + + private static MetaActionKind ActionKind(int type) => type switch + { + 0 => MetaActionKind.None, + 1 => MetaActionKind.SetMetaState, + 2 => MetaActionKind.ChatCommand, + 3 => MetaActionKind.All, + 4 => MetaActionKind.LoadEmbeddedNavigationRoute, + 5 => MetaActionKind.CallMetaState, + 6 => MetaActionKind.ReturnFromCall, + 7 => MetaActionKind.ExpressionAction, + 8 => MetaActionKind.ChatExpression, + 9 => MetaActionKind.SetWatchdog, + 10 => MetaActionKind.ClearWatchdog, + 11 => MetaActionKind.GetVtankOption, + 12 => MetaActionKind.SetVtankOption, + 13 => MetaActionKind.CreateView, + 14 => MetaActionKind.DestroyView, + 15 => MetaActionKind.DestroyAllViews, + _ => throw new FormatException($"Unknown VTank Meta action type {type}."), + }; + + private static string Number(double value) + { + if (!double.IsFinite(value)) + throw new InvalidOperationException("VTank Meta numbers must be finite."); + return value.ToString("R", CultureInfo.InvariantCulture); + } + + private static int IntValue(double value) + { + if (!double.IsFinite(value) || value != Math.Truncate(value)) + throw new InvalidOperationException("VTank Meta integer fields require whole numbers."); + return checked((int)value); + } + + private static void CheckDepth(LineReader reader, int depth) + { + if (depth > MaximumNesting) + throw reader.Error("VTank Meta nesting is too deep."); + } + + private static void CheckDepth(int depth) + { + if (depth > MaximumNesting) + throw new InvalidOperationException("VTank Meta nesting is too deep."); + } + + private sealed class LineReader + { + private readonly List _lines; + private int _index; + + public LineReader(string source) + { + string normalized = NormalizeNewlines(source ?? string.Empty); + _lines = normalized.Split('\n', StringSplitOptions.None).ToList(); + if (_lines.Count != 0 && _lines[^1].Length == 0) + _lines.RemoveAt(_lines.Count - 1); + } + + public string Read() + { + if (_index >= _lines.Count) + throw Error("Unexpected end of VTank Meta data."); + return _lines[_index++]; + } + + public string ReadExpected(string expected) + { + string actual = Read(); + if (!actual.Equals(expected, StringComparison.Ordinal)) + throw Error($"Expected '{expected}', found '{actual}'."); + return actual; + } + + public void Expect(params string[] expected) + { + foreach (string value in expected) + ReadExpected(value); + } + + public void Expect(string[] first, params string[] rest) + { + Expect(first); + Expect(rest); + } + + public int ReadInt() => int.Parse( + Read(), NumberStyles.Integer, CultureInfo.InvariantCulture); + + public int ReadInt(out string line) + { + line = Read(); + return int.Parse(line, NumberStyles.Integer, CultureInfo.InvariantCulture); + } + + public int ReadCount() + { + int value = ReadInt(); + if (value is < 0 or > MaximumRules) + throw Error("Invalid VTank Meta collection count."); + return value; + } + + public int ReadCount(out string line) + { + int value = ReadInt(out line); + if (value is < 0 or > MaximumRules) + throw Error("Invalid VTank Meta collection count."); + return value; + } + + public double ReadDouble() => double.Parse( + Read(), NumberStyles.Float, CultureInfo.InvariantCulture); + + public string ReadByteArray(int length) + { + if (length < 0) + throw Error("Invalid VTank Meta byte-array length."); + string first = Read(); + if (first.Length >= length) + { + string value = first[..length]; + string remainder = first[length..]; + if (remainder.Length != 0) + _lines.Insert(_index, remainder); + return value; + } + + var valueBuilder = new System.Text.StringBuilder(first); + while (valueBuilder.Length < length && _index < _lines.Count) + { + valueBuilder.Append("\r\n"); + valueBuilder.Append(Read()); + } + if (valueBuilder.Length < length) + throw Error("Truncated VTank Meta byte array."); + string combined = valueBuilder.ToString(); + string result = combined[..length]; + string remaining = combined[length..]; + if (remaining.Length != 0) + _lines.Insert(_index, remaining.TrimStart('\r', '\n')); + return result; + } + + public void ExpectEnd() + { + while (_index < _lines.Count && _lines[_index].Length == 0) + _index++; + if (_index != _lines.Count) + throw Error($"Unexpected trailing VTank Meta data '{_lines[_index]}'."); + } + + public FormatException Error(string message) => + new($"VTank Meta line {Math.Min(_index + 1, _lines.Count + 1)}: {message}"); + } + + private sealed class LineWriter + { + private readonly List _lines = []; + private readonly List _buggedByteArrays = []; + + public void Add(params object?[] values) + { + foreach (object? value in values) + _lines.Add(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty); + } + + public void Add(string[] first, params object?[] rest) + { + Add(first.Cast().ToArray()); + Add(rest); + } + + public void AddBuggedByteArray(string value) + { + _buggedByteArrays.Add(_lines.Count); + _lines.Add(value ?? string.Empty); + } + + public string Finish() + { + foreach (int index in _buggedByteArrays.OrderDescending()) + { + if (index + 1 >= _lines.Count) + throw new InvalidOperationException("CreateView cannot terminate a VTank Meta record."); + _lines[index] += _lines[index + 1]; + _lines.RemoveAt(index + 1); + } + return string.Join("\r\n", _lines) + "\r\n"; + } + } +} diff --git a/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs b/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs new file mode 100644 index 00000000..88c8561a --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs @@ -0,0 +1,303 @@ +using System.Globalization; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugins.MossTank; + +/// Reader for VTank's verbatim uTank2 NAV 1.2 format. +internal static class VtankNavRouteSerializer +{ + private const string Header = "uTank2 NAV 1.2"; + + public static string Save(NavigationSettings source) + { + ArgumentNullException.ThrowIfNull(source); + var writer = new StringWriter(CultureInfo.InvariantCulture) + { + NewLine = "\r\n", + }; + writer.WriteLine(Header); + writer.WriteLine(source.Mode switch + { + RouteMode.Circular => 1, + RouteMode.Linear => 2, + RouteMode.Target => 3, + RouteMode.Once => 4, + _ => throw new InvalidOperationException("Unknown navigation type."), + }); + if (source.Mode == RouteMode.Target) + { + writer.WriteLine(source.FollowTargetName ?? string.Empty); + writer.WriteLine(unchecked((int)source.FollowTargetObjectId)); + return writer.ToString(); + } + + writer.WriteLine(source.Waypoints.Count); + foreach (RouteWaypoint waypoint in source.Waypoints) + WriteWaypoint(writer, waypoint); + return writer.ToString(); + } + + public static bool TryLoad( + string source, + NavigationSettings target, + ISpellCatalog spells, + out string error) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(spells); + try + { + string nav = UnwrapEmbedded(source); + using var reader = new StringReader(nav); + if (!ReadLine(reader).Equals(Header, StringComparison.Ordinal)) + throw new FormatException("Nav file version does not match uTank2 NAV 1.2."); + + var parsed = new NavigationSettings + { + Enabled = target.Enabled, + Priority = target.Priority, + MinimumDistanceMeters = target.MinimumDistanceMeters, + FollowAroundCorners = target.FollowAroundCorners, + OpenDoors = target.OpenDoors, + Mode = ReadInt(reader) switch + { + 1 => RouteMode.Circular, + 2 => RouteMode.Linear, + 3 => RouteMode.Target, + 4 => RouteMode.Once, + _ => throw new FormatException("Unknown VTank navigation type."), + }, + }; + + if (parsed.Mode == RouteMode.Target) + { + parsed.FollowTargetName = ReadLine(reader); + parsed.FollowTargetObjectId = unchecked((uint)ReadInt(reader)); + } + else + { + int count = ReadInt(reader); + if (count is < 0 or > 100_000) + throw new FormatException("Invalid VTank waypoint count."); + for (int index = 0; index < count; index++) + parsed.Waypoints.Add(ReadWaypoint(reader, spells)); + } + + Apply(parsed, target); + error = string.Empty; + return true; + } + catch (Exception exception) when (exception is FormatException + or OverflowException or EndOfStreamException) + { + error = exception.Message; + return false; + } + } + + private static RouteWaypoint ReadWaypoint( + TextReader reader, + ISpellCatalog spells) + { + int type = ReadInt(reader); + double eastWest = ReadDouble(reader); + double northSouth = ReadDouble(reader); + double elevation = ReadDouble(reader); + _ = ReadLine(reader); // historical unused coordinate component + var waypoint = new RouteWaypoint + { + Type = type switch + { + 0 => RouteWaypointType.Point, + 1 => RouteWaypointType.Portal, + 2 => RouteWaypointType.Recall, + 3 => RouteWaypointType.Pause, + 4 => RouteWaypointType.ChatCommand, + 5 => RouteWaypointType.OpenVendor, + 6 => RouteWaypointType.PortalByName, + 7 => RouteWaypointType.UseNpc, + 8 => RouteWaypointType.Checkpoint, + 9 => RouteWaypointType.Jump, + _ => throw new FormatException($"Unknown VTank waypoint type {type}."), + }, + Position = Position(eastWest, northSouth, elevation), + }; + + switch (type) + { + case 1: + waypoint.ObjectId = unchecked((uint)ReadInt(reader)); + break; + case 2: + waypoint.RecallSpellId = checked((uint)ReadInt(reader)); + if (spells.TryGet(waypoint.RecallSpellId, out PluginSpellInfo spell)) + waypoint.RecallSpellName = spell.Name; + break; + case 3: + waypoint.DurationMilliseconds = ReadInt(reader); + break; + case 4: + waypoint.Text = ReadLine(reader); + break; + case 5: + waypoint.ObjectId = unchecked((uint)ReadInt(reader)); + waypoint.ObjectName = ReadLine(reader); + break; + case 6: + case 7: + waypoint.ObjectName = ReadLine(reader); + waypoint.LegacyObjectClass = ReadInt(reader); + waypoint.LegacyReferenceValid = ReadBoolean(reader); + double referenceEastWest = ReadDouble(reader); + double referenceNorthSouth = ReadDouble(reader); + double referenceElevation = ReadDouble(reader); + waypoint.Position = Position( + referenceEastWest, + referenceNorthSouth, + referenceElevation); + break; + case 9: + waypoint.JumpHeadingDegrees = checked((float)ReadDouble(reader)); + waypoint.JumpRun = ReadBoolean(reader); + ParseJump(ReadLine(reader), waypoint); + break; + } + return waypoint; + } + + private static void WriteWaypoint(TextWriter writer, RouteWaypoint waypoint) + { + int type = (int)waypoint.Type; + writer.WriteLine(type.ToString(CultureInfo.InvariantCulture)); + WriteDouble(writer, waypoint.Position.EastWest); + WriteDouble(writer, waypoint.Position.NorthSouth); + WriteDouble(writer, waypoint.Position.Elevation); + writer.WriteLine("0"); + switch (waypoint.Type) + { + case RouteWaypointType.Point: + case RouteWaypointType.Checkpoint: + break; + case RouteWaypointType.Portal: + writer.WriteLine(unchecked((int)waypoint.ObjectId) + .ToString(CultureInfo.InvariantCulture)); + break; + case RouteWaypointType.Recall: + writer.WriteLine(waypoint.RecallSpellId + .ToString(CultureInfo.InvariantCulture)); + break; + case RouteWaypointType.Pause: + writer.WriteLine(waypoint.DurationMilliseconds + .ToString(CultureInfo.InvariantCulture)); + break; + case RouteWaypointType.ChatCommand: + writer.WriteLine(waypoint.Text ?? string.Empty); + break; + case RouteWaypointType.OpenVendor: + writer.WriteLine(unchecked((int)waypoint.ObjectId) + .ToString(CultureInfo.InvariantCulture)); + writer.WriteLine(waypoint.ObjectName ?? string.Empty); + break; + case RouteWaypointType.PortalByName: + case RouteWaypointType.UseNpc: + writer.WriteLine(waypoint.ObjectName ?? string.Empty); + int objectClass = waypoint.LegacyObjectClass != 0 + ? waypoint.LegacyObjectClass + : waypoint.Type == RouteWaypointType.PortalByName ? 14 : 37; + writer.WriteLine(objectClass.ToString(CultureInfo.InvariantCulture)); + writer.WriteLine(waypoint.LegacyReferenceValid + .ToString(CultureInfo.InvariantCulture)); + WriteDouble(writer, waypoint.Position.EastWest); + WriteDouble(writer, waypoint.Position.NorthSouth); + WriteDouble(writer, waypoint.Position.Elevation); + break; + case RouteWaypointType.Jump: + WriteDouble(writer, waypoint.JumpHeadingDegrees); + writer.WriteLine(waypoint.JumpRun.ToString(CultureInfo.InvariantCulture)); + string suffix = waypoint.JumpDirection switch + { + RouteJumpDirection.StrafeLeft => "4", + RouteJumpDirection.StrafeRight => "5", + _ => "3", + }; + writer.WriteLine( + waypoint.JumpChargeMilliseconds.ToString( + "0.0000", + CultureInfo.InvariantCulture) + + suffix); + break; + default: + throw new InvalidOperationException( + $"Unknown waypoint type {waypoint.Type}."); + } + } + + private static void WriteDouble(TextWriter writer, double value) => + writer.WriteLine(Convert.ToString(value, CultureInfo.InvariantCulture)); + + private static void ParseJump(string source, RouteWaypoint target) + { + string value = source.Trim(); + char suffix = value.Length == 0 ? '\0' : value[^1]; + bool encoded = suffix is '3' or '4' or '5' + && value.Length >= 6 + && value[^6] == '.'; + string milliseconds = encoded ? value[..^1] : value; + target.JumpChargeMilliseconds = checked((int)Math.Round( + double.Parse(milliseconds, NumberStyles.Float, CultureInfo.InvariantCulture), + MidpointRounding.AwayFromZero)); + target.JumpDirection = suffix switch + { + '4' when encoded => RouteJumpDirection.StrafeLeft, + '5' when encoded => RouteJumpDirection.StrafeRight, + _ => RouteJumpDirection.Forward, + }; + } + + private static string UnwrapEmbedded(string source) + { + string normalized = source?.Replace("\r\n", "\n", StringComparison.Ordinal) + ?? string.Empty; + if (normalized.StartsWith(Header, StringComparison.Ordinal)) + return normalized; + using var reader = new StringReader(normalized); + _ = ReadLine(reader); // embedded route display name + _ = ReadInt(reader); // embedded point count + return reader.ReadToEnd(); + } + + private static PluginNavigationPosition Position( + double eastWest, + double northSouth, + double elevation) => new( + 0u, + eastWest, + northSouth, + elevation, + 0f, + IsOutdoor: true); + + private static void Apply(NavigationSettings source, NavigationSettings target) + { + target.Mode = source.Mode; + target.FollowTargetObjectId = source.FollowTargetObjectId; + target.FollowTargetName = source.FollowTargetName; + target.Waypoints.Clear(); + target.Waypoints.AddRange(source.Waypoints.Select(static value => value.Clone())); + } + + private static string ReadLine(TextReader reader) => + reader.ReadLine() ?? throw new EndOfStreamException("Unexpected end of VTank nav data."); + + private static int ReadInt(TextReader reader) => int.Parse( + ReadLine(reader), + NumberStyles.Integer, + CultureInfo.InvariantCulture); + + private static double ReadDouble(TextReader reader) => double.Parse( + ReadLine(reader), + NumberStyles.Float, + CultureInfo.InvariantCulture); + + private static bool ReadBoolean(TextReader reader) => bool.Parse(ReadLine(reader)); +} diff --git a/src/AcDream.Plugins.MossTank/VtankOptionCatalog.cs b/src/AcDream.Plugins.MossTank/VtankOptionCatalog.cs new file mode 100644 index 00000000..8947b9e5 --- /dev/null +++ b/src/AcDream.Plugins.MossTank/VtankOptionCatalog.cs @@ -0,0 +1,221 @@ +namespace AcDream.Plugins.MossTank; + +/// +/// Exact 137-row Settings table from VTank's shipped +/// uTank2.Resources.defaultsettings.usd. Order is retained because +/// /vt opt list presents the database order four entries per line. +/// +internal static class VtankOptionCatalog +{ + internal static readonly string[] Names = + [ + "EnableLooting", "EnableNav", "EnableBuffing", "EnableCombat", + "SpellDiffExcessThreshold-Hunt", "SpellDiffExcessThreshold-Buff", + "ArrowheadFletchDiffExcessThreshold", "Recharge-Norm-HitP", + "Recharge-Norm-Stam", "Recharge-Norm-Mana", "Recharge-NoTarg-HitP", + "Recharge-NoTarg-Stam", "Recharge-NoTarg-Mana", "Recharge-Helper-HitP", + "Recharge-Helper-Stam", "Recharge-Helper-Mana", "DoHelp", + "AttackDistance", "AttackMinimumDistance", "ApproachDistance", + "RingDistance", "CorpseApproachRange-Max", "CorpseApproachRange-Min", + "NavCloseStopRange", "NavFarStopRange", "UsePortalDistance", + "HelperDistanceHitP", "HelperDistanceStam", "HelperDistanceMana", + "MinimumRingTargets", "DefaultMeleeAttackHeight", "CastDispelSelf", + "UseDispelItems", "AutoCram", "AutoStack", "ReadUnknownScrolls", + "UseDispelDrum", "SwitchWandsToDebuff", "AutoCraftItems", + "UseHealersHeart", "JumpOutWandCasting", "LootAllCorpses", + "LootFellowCorpses", "DoJiggle", "RandomHelperBuffs", + "RandomHelperIntervalSeconds", "IdlePeaceMode", "TargetLock", + "StopMacroOnDeath", "UseArcs", "ArcRange", "TargetSelectMethod", + "TargetSelectAngleRange", "IdleBuffTopoff", "IdleBuffTopoffTimeSeconds", + "RebuffTimeRemainingSeconds", "RefillWornMana", + "RefillWornMana-Item-ManaPercent", "BuffProfile-Prots", + "BuffProfile-Banes", "BuffProfile_Prots", "BuffProfile_Banes", + "DebuffEachFirst", "AutoAttackPower", "LootPriorityBoost", + "CorpseCacheTimeoutMinutes", "CorpseItemAppearanceTimeoutSeconds", + "CorpseItemIDTimeoutSeconds", "DebuffSelectionMethod", + "ManaStoneLootCount", "ManaTankMinimumMana", "SplitPeas", + "SpellCompMin-Critical", "SpellCompMin-Normal", "SpellCompMin-Idle", + "RechargeBoostTimeSeconds", "RechargeBoostAmount", "UseSpecialAmmo", + "OpenDoors", "DoorIDRange", "DoorOpenRange", + "DoorLockpickDiffExcessThreshold", "ManaChargesWhenOff", + "AutoFellowManagement", "MinimumHealKitSuccessChance", + "UseKitsInMagicMode", "StaminaToHealthMultiplier", + "ManaToHealthMultiplier", "NavPriorityBoost", "DeleteGhostMonsters", + "GhostMonsterSpellAttemptCount", "WhoYouGonnaCall", + "BlacklistMonsterAttemptCount", "BlacklistMonsterTimeoutSeconds", + "CombineSalvage", "LootOnlyRareCorpses", + "DeleteGhostMonstersByHPTracker", "GhostDeleteHPTrackerSeconds", + "GoToPeaceModeToUseKits", "UseRecklessness", "DebuffPrecastSeconds", + "ClearLevelBoostFlagOnCast", "IdleCraftCount_HealthKits", + "IdleCraftCount_StamKits", "IdleCraftCount_ManaKits", + "IdleCraftCount_HealthFood", "IdleCraftCount_StamFood", + "IdleCraftCount_ManaFood", "BuffCastRecast_Seconds", + "BuffCastRecastReset_Seconds", "EnableMeta", "BlacklistedSpellComps", + "DropToPeaceModeRetryCount", "FollowAroundCorners", + "BlacklistCorpseOpenAttemptCount", "BlacklistCorpseOpenTimeoutSeconds", + "SummonPets", "PetRangeMode", "PetCustomRange", "PetRefillCount-Idle", + "PetRefillCount-Normal", "CorpseOpenTimeoutSeconds", + "PetMonsterDensity", "CorpseLootItemMaxAttempts", "FastCastBuffs", + "UseBreakableTurnTo", "UseProjectileAwareness", + "CollisionProjectileRadius", "CollisionStepDistance", + "ShowCollisionDebug", "MaximumCollisionChecksPerTick", "SpellRangeFudge", + "BuffWithUntrained-Item", "BuffWithUntrained-Creature", + "BuffWithUntrained-Life", "AllowDebuffFallback", "RechargeHandlerSet", + ]; + + // Scalar defaults are read verbatim from VTank's shipped Settings table. + // RechargeHandlerSet is the one non-scalar row and is represented by its + // table identity; the Vitals policy owns its live ordered handlers. + private static readonly IReadOnlyDictionary Defaults = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["EnableLooting"] = MonsterValue.FromBoolean(false), + ["EnableNav"] = MonsterValue.FromBoolean(false), + ["EnableBuffing"] = MonsterValue.FromBoolean(true), + ["EnableCombat"] = MonsterValue.FromBoolean(true), + ["SpellDiffExcessThreshold-Hunt"] = MonsterValue.FromNumber(25d), + ["SpellDiffExcessThreshold-Buff"] = MonsterValue.FromNumber(5d), + ["ArrowheadFletchDiffExcessThreshold"] = MonsterValue.FromNumber(10d), + ["Recharge-Norm-HitP"] = MonsterValue.FromNumber(75d), + ["Recharge-Norm-Stam"] = MonsterValue.FromNumber(50d), + ["Recharge-Norm-Mana"] = MonsterValue.FromNumber(50d), + ["Recharge-NoTarg-HitP"] = MonsterValue.FromNumber(1d), + ["Recharge-NoTarg-Stam"] = MonsterValue.FromNumber(1d), + ["Recharge-NoTarg-Mana"] = MonsterValue.FromNumber(1d), + ["Recharge-Helper-HitP"] = MonsterValue.FromNumber(20d), + ["Recharge-Helper-Stam"] = MonsterValue.FromNumber(1d), + ["Recharge-Helper-Mana"] = MonsterValue.FromNumber(1d), + ["DoHelp"] = MonsterValue.FromBoolean(true), + ["AttackDistance"] = MonsterValue.FromNumber(0.0208333333333333d), + ["AttackMinimumDistance"] = MonsterValue.FromNumber(0d), + ["ApproachDistance"] = MonsterValue.FromNumber(0d), + ["RingDistance"] = MonsterValue.FromNumber(0.0208333333333333d), + ["CorpseApproachRange-Max"] = MonsterValue.FromNumber(0d), + ["CorpseApproachRange-Min"] = MonsterValue.FromNumber(0.014d), + ["NavCloseStopRange"] = MonsterValue.FromNumber(0.00833333333333333d), + ["NavFarStopRange"] = MonsterValue.FromNumber(999999d), + ["UsePortalDistance"] = MonsterValue.FromNumber(0.0166666666666667d), + ["HelperDistanceHitP"] = MonsterValue.FromNumber(0.310416666666667d), + ["HelperDistanceStam"] = MonsterValue.FromNumber(0.310416666666667d), + ["HelperDistanceMana"] = MonsterValue.FromNumber(0.166666666666667d), + ["MinimumRingTargets"] = MonsterValue.FromNumber(4d), + ["DefaultMeleeAttackHeight"] = MonsterValue.FromNumber(2d), + ["CastDispelSelf"] = MonsterValue.FromBoolean(false), + ["UseDispelItems"] = MonsterValue.FromBoolean(false), + ["AutoCram"] = MonsterValue.FromBoolean(false), + ["AutoStack"] = MonsterValue.FromBoolean(true), + ["ReadUnknownScrolls"] = MonsterValue.FromBoolean(true), + ["UseDispelDrum"] = MonsterValue.FromBoolean(false), + ["SwitchWandsToDebuff"] = MonsterValue.FromBoolean(false), + ["AutoCraftItems"] = MonsterValue.FromBoolean(true), + ["UseHealersHeart"] = MonsterValue.FromBoolean(true), + ["JumpOutWandCasting"] = MonsterValue.FromBoolean(false), + ["LootAllCorpses"] = MonsterValue.FromBoolean(false), + ["LootFellowCorpses"] = MonsterValue.FromBoolean(false), + ["DoJiggle"] = MonsterValue.FromBoolean(false), + ["RandomHelperBuffs"] = MonsterValue.FromBoolean(false), + ["RandomHelperIntervalSeconds"] = MonsterValue.FromNumber(5d), + ["IdlePeaceMode"] = MonsterValue.FromBoolean(false), + ["TargetLock"] = MonsterValue.FromBoolean(false), + ["StopMacroOnDeath"] = MonsterValue.FromBoolean(true), + ["UseArcs"] = MonsterValue.FromNumber(1d), + ["ArcRange"] = MonsterValue.FromNumber(0.0208333333333333d), + ["TargetSelectMethod"] = MonsterValue.FromNumber(3d), + ["TargetSelectAngleRange"] = MonsterValue.FromNumber(0.0208333333333333d), + ["IdleBuffTopoff"] = MonsterValue.FromBoolean(false), + ["IdleBuffTopoffTimeSeconds"] = MonsterValue.FromNumber(1200d), + ["RebuffTimeRemainingSeconds"] = MonsterValue.FromNumber(300d), + ["RefillWornMana"] = MonsterValue.FromBoolean(true), + ["RefillWornMana-Item-ManaPercent"] = MonsterValue.FromNumber(33d), + ["BuffProfile-Prots"] = MonsterValue.FromText("ALFCBPS"), + ["BuffProfile-Banes"] = MonsterValue.FromText("ALFCBPS"), + ["BuffProfile_Prots"] = MonsterValue.FromNumber(2d), + ["BuffProfile_Banes"] = MonsterValue.FromNumber(2d), + ["DebuffEachFirst"] = MonsterValue.FromNumber(1d), + ["AutoAttackPower"] = MonsterValue.FromBoolean(true), + ["LootPriorityBoost"] = MonsterValue.FromBoolean(false), + ["CorpseCacheTimeoutMinutes"] = MonsterValue.FromNumber(60d), + ["CorpseItemAppearanceTimeoutSeconds"] = MonsterValue.FromNumber(6d), + ["CorpseItemIDTimeoutSeconds"] = MonsterValue.FromNumber(60d), + ["DebuffSelectionMethod"] = MonsterValue.FromNumber(2d), + ["ManaStoneLootCount"] = MonsterValue.FromNumber(4d), + ["ManaTankMinimumMana"] = MonsterValue.FromNumber(1000d), + ["SplitPeas"] = MonsterValue.FromBoolean(true), + ["SpellCompMin-Critical"] = MonsterValue.FromNumber(4d), + ["SpellCompMin-Normal"] = MonsterValue.FromNumber(20d), + ["SpellCompMin-Idle"] = MonsterValue.FromNumber(20d), + ["RechargeBoostTimeSeconds"] = MonsterValue.FromNumber(5d), + ["RechargeBoostAmount"] = MonsterValue.FromNumber(40d), + ["UseSpecialAmmo"] = MonsterValue.FromNumber(0d), + ["OpenDoors"] = MonsterValue.FromBoolean(false), + ["DoorIDRange"] = MonsterValue.FromNumber(0.0833333333333333d), + ["DoorOpenRange"] = MonsterValue.FromNumber(0.0166666666666667d), + ["DoorLockpickDiffExcessThreshold"] = MonsterValue.FromNumber(-50d), + ["ManaChargesWhenOff"] = MonsterValue.FromBoolean(true), + ["AutoFellowManagement"] = MonsterValue.FromBoolean(true), + ["MinimumHealKitSuccessChance"] = MonsterValue.FromNumber(95d), + ["UseKitsInMagicMode"] = MonsterValue.FromBoolean(true), + ["StaminaToHealthMultiplier"] = MonsterValue.FromNumber(1.9d), + ["ManaToHealthMultiplier"] = MonsterValue.FromNumber(2.8d), + ["NavPriorityBoost"] = MonsterValue.FromBoolean(false), + ["DeleteGhostMonsters"] = MonsterValue.FromBoolean(true), + ["GhostMonsterSpellAttemptCount"] = MonsterValue.FromNumber(200d), + ["WhoYouGonnaCall"] = MonsterValue.FromBoolean(true), + ["BlacklistMonsterAttemptCount"] = MonsterValue.FromNumber(4d), + ["BlacklistMonsterTimeoutSeconds"] = MonsterValue.FromNumber(120d), + ["CombineSalvage"] = MonsterValue.FromBoolean(true), + ["LootOnlyRareCorpses"] = MonsterValue.FromBoolean(false), + ["DeleteGhostMonstersByHPTracker"] = MonsterValue.FromBoolean(true), + ["GhostDeleteHPTrackerSeconds"] = MonsterValue.FromNumber(30d), + ["GoToPeaceModeToUseKits"] = MonsterValue.FromBoolean(false), + ["UseRecklessness"] = MonsterValue.FromBoolean(true), + ["DebuffPrecastSeconds"] = MonsterValue.FromNumber(5d), + ["ClearLevelBoostFlagOnCast"] = MonsterValue.FromBoolean(true), + ["IdleCraftCount_HealthKits"] = MonsterValue.FromNumber(2d), + ["IdleCraftCount_StamKits"] = MonsterValue.FromNumber(2d), + ["IdleCraftCount_ManaKits"] = MonsterValue.FromNumber(2d), + ["IdleCraftCount_HealthFood"] = MonsterValue.FromNumber(15d), + ["IdleCraftCount_StamFood"] = MonsterValue.FromNumber(15d), + ["IdleCraftCount_ManaFood"] = MonsterValue.FromNumber(15d), + ["BuffCastRecast_Seconds"] = MonsterValue.FromNumber(30d), + ["BuffCastRecastReset_Seconds"] = MonsterValue.FromNumber(30d), + ["EnableMeta"] = MonsterValue.FromBoolean(false), + ["BlacklistedSpellComps"] = MonsterValue.FromText(string.Empty), + ["DropToPeaceModeRetryCount"] = MonsterValue.FromNumber(34d), + ["FollowAroundCorners"] = MonsterValue.FromBoolean(true), + ["BlacklistCorpseOpenAttemptCount"] = MonsterValue.FromNumber(30d), + ["BlacklistCorpseOpenTimeoutSeconds"] = MonsterValue.FromNumber(200d), + ["SummonPets"] = MonsterValue.FromBoolean(true), + ["PetRangeMode"] = MonsterValue.FromNumber(0d), + ["PetCustomRange"] = MonsterValue.FromNumber(0.0208333333333333d), + ["PetRefillCount-Idle"] = MonsterValue.FromNumber(3d), + ["PetRefillCount-Normal"] = MonsterValue.FromNumber(1d), + ["CorpseOpenTimeoutSeconds"] = MonsterValue.FromNumber(1.5d), + ["PetMonsterDensity"] = MonsterValue.FromNumber(1d), + ["CorpseLootItemMaxAttempts"] = MonsterValue.FromNumber(20d), + ["FastCastBuffs"] = MonsterValue.FromBoolean(false), + ["UseBreakableTurnTo"] = MonsterValue.FromBoolean(true), + ["UseProjectileAwareness"] = MonsterValue.FromBoolean(true), + ["CollisionProjectileRadius"] = MonsterValue.FromNumber(0.4d), + ["CollisionStepDistance"] = MonsterValue.FromNumber(0.7d), + ["ShowCollisionDebug"] = MonsterValue.FromBoolean(false), + ["MaximumCollisionChecksPerTick"] = MonsterValue.FromNumber(500d), + ["SpellRangeFudge"] = MonsterValue.FromNumber(1d), + ["BuffWithUntrained-Item"] = MonsterValue.FromNumber(80d), + ["BuffWithUntrained-Creature"] = MonsterValue.FromNumber(80d), + ["BuffWithUntrained-Life"] = MonsterValue.FromNumber(80d), + ["AllowDebuffFallback"] = MonsterValue.FromBoolean(false), + ["RechargeHandlerSet"] = MonsterValue.FromText("RechargeHandlerSet"), + }; + + internal static bool IsKnown(string name) => + Names.Contains(name, StringComparer.OrdinalIgnoreCase); + + internal static string Canonical(string name) => + Names.First(value => value.Equals(name, StringComparison.OrdinalIgnoreCase)); + + internal static MonsterValue Default(string name) => + Defaults.TryGetValue(name, out MonsterValue value) + ? value + : MonsterValue.FromNumber(0d); +} diff --git a/src/AcDream.Plugins.MossTank/mosstank-settings.xml b/src/AcDream.Plugins.MossTank/mosstank-settings.xml deleted file mode 100644 index 47bf4a27..00000000 --- a/src/AcDream.Plugins.MossTank/mosstank-settings.xml +++ /dev/null @@ -1,53 +0,0 @@ - - - - void Publish(T command) where T : notnull; } + +/// +/// Optional local-command extension carried by a command bus. The chat router +/// checks it after retail client commands and before unknown commands are sent +/// to the server. +/// +public interface IPluginCommandBus : ICommandBus +{ + bool TryHandlePluginCommand(string commandLine); +} diff --git a/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs b/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs index 9296a7c0..73160965 100644 --- a/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs +++ b/src/AcDream.Runtime/Chat/LiveChatCommandRoute.cs @@ -303,11 +303,17 @@ public sealed class LiveChatCommandRoute /// Stable host-owned bus over a replaceable generation route. A retained /// login-command runner never captures an obsolete transport. /// -public sealed class LiveChatCommandSurface : ICommandBus +public sealed class LiveChatCommandSurface : IPluginCommandBus { private readonly object _gate = new(); + private readonly Func? _tryHandlePluginCommand; private LiveChatCommandRoute? _active; + public LiveChatCommandSurface(Func? tryHandlePluginCommand = null) + { + _tryHandlePluginCommand = tryHandlePluginCommand; + } + public ILiveSessionCommandRouting Attach(LiveChatCommandRoute route) { ArgumentNullException.ThrowIfNull(route); @@ -331,6 +337,9 @@ public sealed class LiveChatCommandSurface : ICommandBus route?.Publish(command); } + public bool TryHandlePluginCommand(string commandLine) => + _tryHandlePluginCommand?.Invoke(commandLine) == true; + private void Release(LiveChatCommandRoute expected) { expected.Dispose(); diff --git a/src/AcDream.Runtime/GameRuntimeActionViews.cs b/src/AcDream.Runtime/GameRuntimeActionViews.cs index c8726b86..944295d7 100644 --- a/src/AcDream.Runtime/GameRuntimeActionViews.cs +++ b/src/AcDream.Runtime/GameRuntimeActionViews.cs @@ -11,7 +11,13 @@ public readonly record struct RuntimeCombatAttackSnapshot( bool BuildInProgress, bool RequestInProgress, float RequestedPower, - bool RepeatAttackInProgress = false); + bool RepeatAttackInProgress = false, + bool ServerResponsePending = false) +{ + public long CompletionRevision { get; init; } + public uint CompletionSequence { get; init; } + public uint CompletionWeenieError { get; init; } +} public readonly record struct RuntimeSpellCastSnapshot( long Revision, diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index 487d70db..d28ed25d 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -17,7 +17,8 @@ public readonly record struct MovementInput( bool TurnRight = false, bool Run = false, float MouseDeltaX = 0f, - bool Jump = false); + bool Jump = false, + bool IsPersistentCommand = false); /// /// Typed construction policy for the local movement owner. Server-authoritative @@ -340,6 +341,26 @@ public sealed class PlayerMovementController public uint CellId { get; private set; } public AcDream.Core.Physics.Position CellPosition => _body.CellPosition; + /// + /// Current local-player position for Runtime consumers. The physics body's + /// carried intentionally owns cell + /// identity and the cell-local origin only; its frame rotation is not + /// rewritten by animation root motion. Consumers that need the live facing + /// direction must therefore combine that carried translation with the + /// authoritative body orientation, just like the outbound movement path. + /// + internal AcDream.Core.Physics.Position CurrentCellPosition + { + get + { + AcDream.Core.Physics.Position carried = _body.CellPosition; + return new AcDream.Core.Physics.Position( + carried.ObjCellId, + carried.Frame.Origin, + _body.Orientation); + } + } + /// /// True only when the most recent visible or Hidden object update admitted /// at least one complete retail quantum. Presentation uses this to rebuild @@ -414,11 +435,7 @@ public sealed class PlayerMovementController out AcDream.Core.Physics.Position outboundPosition) { EnsurePublishedForRuntimeOperation(); - AcDream.Core.Physics.Position canonical = _body.CellPosition; - outboundPosition = new AcDream.Core.Physics.Position( - canonical.ObjCellId, - canonical.Frame.Origin, - _body.Orientation); + outboundPosition = CurrentCellPosition; return PositionFrameValidation.IsValid( outboundPosition.ObjCellId, outboundPosition.Frame.Origin, @@ -2512,6 +2529,27 @@ public sealed class PlayerMovementController bool movementEventRequested = externallyRequestedMovementEvent; { + // Plugin/headless movement is a persistent command level rather + // than a sampled physical key. A server-authored posture change + // (notably the Magic + Ready acknowledgement emitted while + // MossTank is facing a spell target) legitimately takes movement + // control, but it must not permanently erase a still-active + // command intent. Retake through the same retail + // CommandInterpreter boundary before edge detection; clearing the + // prior levels below makes this frame re-dispatch the held axes and + // publish one fresh autonomous movement event. Physical keyboard + // snapshots leave IsPersistentCommand false and retain their exact + // edge-driven behavior. + bool persistentMovementHeld = input.IsPersistentCommand + && (input.Forward + || input.Backward + || input.StrafeLeft + || input.StrafeRight + || input.TurnLeft + || input.TurnRight); + if (_controlledByServer && persistentMovementHeld) + TakeControlFromServer(); + bool userInputEdge = input.Run != _prevRunHeld || input.Forward != _prevForwardHeld || input.Backward != _prevBackwardHeld diff --git a/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs b/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs index fcd76db7..204681c1 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeActionState.cs @@ -2,6 +2,7 @@ using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Selection; using AcDream.Core.Spells; +using System.Diagnostics; namespace AcDream.Runtime.Gameplay; @@ -53,6 +54,9 @@ public sealed class RuntimeActionState : IDisposable private long _interactionRevision; private long _combatIntentRevision; private long _magicIntentRevision; + private readonly Func _now; + private readonly Dictionary _healthActivity = []; + private long _healthActivityRevision; public RuntimeActionState( InventoryTransactionState inventoryTransactions, @@ -69,6 +73,8 @@ public sealed class RuntimeActionState : IDisposable ArgumentNullException.ThrowIfNull(combatTargetOperations); ArgumentNullException.ThrowIfNull(combatModeOperations); ArgumentNullException.ThrowIfNull(spellCastOperations); + _now = now ?? (() => + Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency); Selection = new SelectionState(); Combat = new CombatState(); Interaction = new InteractionState(); @@ -77,7 +83,7 @@ public sealed class RuntimeActionState : IDisposable CombatAttack = new RuntimeCombatAttackState( Combat, combatAttackOperations, - now); + _now); CombatTarget = new RuntimeCombatTargetState( Combat, Selection, @@ -111,6 +117,22 @@ public sealed class RuntimeActionState : IDisposable public IRuntimeActionView View { get; } public bool IsDisposed => _disposed; + public bool TryGetHealthActivity( + uint objectId, + out long revision, + out double secondsSinceUpdate) + { + if (!_healthActivity.TryGetValue(objectId, out HealthActivity activity)) + { + revision = 0; + secondsSinceUpdate = double.PositiveInfinity; + return false; + } + revision = activity.Revision; + secondsSinceUpdate = Math.Max(0d, _now() - activity.UpdatedAt); + return true; + } + internal event Action? CombatChanged; public RuntimeActionOwnershipSnapshot CaptureOwnership() => new( @@ -147,6 +169,7 @@ public sealed class RuntimeActionState : IDisposable Try(CombatAttack.ResetSession, ref failures); Try(() => Selection.Reset(), ref failures); Try(Combat.Clear, ref failures); + ClearHealthActivity(); if (failures is not null) { throw new AggregateException( @@ -169,6 +192,7 @@ public sealed class RuntimeActionState : IDisposable Try(CombatAttack.ResetSession, ref failures); Try(() => Selection.Reset(), ref failures); Try(Combat.Clear, ref failures); + ClearHealthActivity(); } finally { @@ -201,12 +225,20 @@ public sealed class RuntimeActionState : IDisposable CombatChanged?.Invoke(); } - private void OnHealthChanged(uint _, float __) + private void OnHealthChanged(uint objectId, float _) { + long revision = ++_healthActivityRevision; + _healthActivity[objectId] = new HealthActivity(revision, _now()); Interlocked.Increment(ref _combatRevision); CombatChanged?.Invoke(); } + private void ClearHealthActivity() + { + _healthActivity.Clear(); + _healthActivityRevision = 0; + } + private void OnInteractionChanged(InteractionModeTransition _) => Interlocked.Increment(ref _interactionRevision); @@ -254,7 +286,13 @@ public sealed class RuntimeActionState : IDisposable owner.CombatAttack.BuildInProgress, owner.CombatAttack.AttackRequestInProgress, owner.CombatAttack.RequestedAttackPower, - owner.CombatAttack.RepeatAttackInProgress), + owner.CombatAttack.RepeatAttackInProgress, + owner.CombatAttack.AttackServerResponsePending) + { + CompletionRevision = owner.CombatAttack.CompletionRevision, + CompletionSequence = owner.CombatAttack.CompletionSequence, + CompletionWeenieError = owner.CombatAttack.CompletionWeenieError, + }, new RuntimeSpellCastSnapshot( Interlocked.Read(ref owner._magicIntentRevision), owner.SpellCast.LastRequestedSpellId ?? 0u, @@ -272,4 +310,6 @@ public sealed class RuntimeActionState : IDisposable return true; } } + + private readonly record struct HealthActivity(long Revision, double UpdatedAt); } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs index 6c0eccb7..14753aa8 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCombatAttackState.cs @@ -110,6 +110,7 @@ public sealed class RuntimeCombatAttackState : IDisposable private float _requestedAttackPower; private float _latestPowerBarLevel; private bool _disposed; + private long _completionRevision; public RuntimeCombatAttackState( CombatState combat, @@ -152,10 +153,19 @@ public sealed class RuntimeCombatAttackState : IDisposable public AttackHeight RequestedHeight { get; private set; } = AttackHeight.Medium; public float DesiredPower { get; private set; } = InitialDesiredPower; public bool AttackRequestInProgress => _attackRequestInProgress; + /// + /// True after an attack request has been emitted and before the matching + /// server completion. Automation must not begin another request during + /// this interval. + /// + public bool AttackServerResponsePending => _attackServerResponsePending; public bool RepeatAttackInProgress => _repeatAttacking; public float RequestedAttackPower => _requestedAttackPower; public bool BuildInProgress => _buildInProgress; public bool IsDisposed => _disposed; + public long CompletionRevision => _completionRevision; + public uint CompletionSequence { get; private set; } + public uint CompletionWeenieError { get; private set; } /// The level retail publishes to the embedded combat meter. public float PowerBarLevel => _buildInProgress @@ -394,8 +404,11 @@ public sealed class RuntimeCombatAttackState : IDisposable StateChanged?.Invoke(); } - private void OnAttackDone(uint _, uint weenieError) + private void OnAttackDone(uint attackSequence, uint weenieError) { + CompletionSequence = attackSequence; + CompletionWeenieError = weenieError; + _completionRevision++; _attackServerResponsePending = false; if (weenieError != 0) _repeatAttacking = false; @@ -471,6 +484,9 @@ public sealed class RuntimeCombatAttackState : IDisposable _attackWhenResponseReceivedPower = 0f; _repeatAttacking = false; _requestedAttackPower = 0f; + _completionRevision = 0; + CompletionSequence = 0u; + CompletionWeenieError = 0u; ResetPowerBar(); } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeCombatModeState.cs b/src/AcDream.Runtime/Gameplay/RuntimeCombatModeState.cs index fbf7e4aa..0d36f62e 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeCombatModeState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeCombatModeState.cs @@ -91,4 +91,42 @@ public sealed class RuntimeCombatModeState RuntimeCombatModeRequestStatus.Sent, nextMode); } + + /// + /// The explicit-mode half of Decal's combat-state primitive used by + /// plugins such as VTank. Unlike , the caller already + /// chose the mode after its equipment policy ran. + /// + public RuntimeCombatModeRequestResult Request(CombatMode mode) + { + if (!_operations.IsInWorld) + { + return new RuntimeCombatModeRequestResult( + RuntimeCombatModeRequestStatus.Inactive, + _combat.CurrentMode); + } + if (mode is not (CombatMode.NonCombat + or CombatMode.Melee + or CombatMode.Missile + or CombatMode.Magic)) + { + return new RuntimeCombatModeRequestResult( + RuntimeCombatModeRequestStatus.Rejected, + _combat.CurrentMode, + "Invalid combat mode."); + } + if (_combat.CurrentMode == mode) + { + return new RuntimeCombatModeRequestResult( + RuntimeCombatModeRequestStatus.Sent, + mode); + } + + _operations.NotifyExplicitCombatModeRequest(); + _operations.SendChangeCombatMode(mode); + _combat.SetCombatMode(mode); + return new RuntimeCombatModeRequestResult( + RuntimeCombatModeRequestStatus.Sent, + mode); + } } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeFriendlyTargetQuery.cs b/src/AcDream.Runtime/Gameplay/RuntimeFriendlyTargetQuery.cs index 59e7d81e..1296a932 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeFriendlyTargetQuery.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeFriendlyTargetQuery.cs @@ -148,6 +148,38 @@ public static class RuntimeFriendlyTargetQuery : null; } + /// Horizontal live-world distance from the local player. + public static bool TryGetDistance( + GameRuntime runtime, + uint guid, + out float distance) + { + ArgumentNullException.ThrowIfNull(runtime); + uint playerGuid = runtime.PlayerIdentity.ServerGuid; + if (playerGuid == 0u + || !runtime.EntityObjects.Entities.TryGetActive( + playerGuid, + out RuntimeEntityRecord player) + || player.Snapshot.Position is not { } playerPosition + || !runtime.EntityObjects.Entities.TryGetActive( + guid, + out RuntimeEntityRecord target) + || target.Snapshot.Position is not { } targetPosition + || (target.FinalPhysicsState + & (PhysicsStateFlags.Hidden | PhysicsStateFlags.NoDraw)) != 0) + { + distance = float.PositiveInfinity; + return false; + } + + Vector3 from = AbsolutePosition(playerPosition); + Vector3 to = AbsolutePosition(targetPosition); + distance = Vector2.Distance( + new Vector2(from.X, from.Y), + new Vector2(to.X, to.Y)); + return true; + } + private static bool IsPlayer(RuntimeEntityRecord record) => EntityCollisionFlagsExt .FromPwdBitfield(record.Snapshot.ObjectDescriptionFlags ?? 0u) diff --git a/src/AcDream.Runtime/Gameplay/RuntimeHostileTargetQuery.cs b/src/AcDream.Runtime/Gameplay/RuntimeHostileTargetQuery.cs index 12a2e420..3d10838e 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeHostileTargetQuery.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeHostileTargetQuery.cs @@ -3,10 +3,35 @@ using AcDream.Core.Combat; using AcDream.Core.Items; using AcDream.Core.Net.Messages; using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.Core.Properties; using AcDream.Runtime.Entities; namespace AcDream.Runtime.Gameplay; +/// +/// One hostile candidate projected from the canonical entity/object owners. +/// Distances are horizontal-world meters and relative angle uses retail's +/// compass convention: 0 straight ahead, negative left, positive right. +/// +public readonly record struct RuntimeHostileTargetSnapshot( + uint ObjectId, + string Name, + uint WeenieClassId, + float Distance, + float RelativeAngleDegrees, + bool IsHealthKnown, + float HealthFraction) +{ + public int SpeciesId { get; init; } + public int MaximumHealth { get; init; } + public bool HasShield { get; init; } + public ushort Incarnation { get; init; } + public long HealthRevision { get; init; } + public double SecondsSinceHealthUpdate { get; init; } = + double.PositiveInfinity; +} + /// /// Presentation-independent hostile-target query over the canonical Runtime /// directory and object table. Graphical hosts may retain their render-aware @@ -15,6 +40,117 @@ namespace AcDream.Runtime.Gameplay; /// public static class RuntimeHostileTargetQuery { + /// + /// Captures every live hostile within a bounded horizontal distance. The + /// returned array is immutable-by-convention and detached from the owner; + /// callers may retain it until their next decision tick. + /// + public static IReadOnlyList Capture( + GameRuntime runtime, + float maximumDistance) + { + ArgumentNullException.ThrowIfNull(runtime); + if (float.IsNaN(maximumDistance) || maximumDistance <= 0f) + return Array.Empty(); + + uint playerGuid = runtime.PlayerIdentity.ServerGuid; + if (playerGuid == 0u + || !runtime.EntityObjects.Entities.TryGetActive( + playerGuid, + out RuntimeEntityRecord playerRecord) + || playerRecord.Snapshot.Position is not { } playerPosition) + { + return Array.Empty(); + } + + Vector3 playerWorld = AbsolutePosition(playerPosition); + float playerHeading = MoveToMath.GetHeading(new Quaternion( + playerPosition.RotationX, + playerPosition.RotationY, + playerPosition.RotationZ, + playerPosition.RotationW)); + float maximumDistanceSquared = maximumDistance * maximumDistance; + ClientObjectTable objects = runtime.InventoryOwner.Objects; + ClientObject? player = objects.Get(playerGuid); + var targets = new List(); + + foreach (RuntimeEntityRecord record + in runtime.EntityObjects.Entities.ActiveRecords) + { + if (record.ServerGuid == playerGuid + || record.Snapshot.Position is not { } position + || (record.FinalPhysicsState + & (PhysicsStateFlags.Hidden + | PhysicsStateFlags.NoDraw)) != 0) + { + continue; + } + + ClientObject? candidate = objects.Get(record.ServerGuid); + if (!CombatTargetPolicy.IsHostileMonster( + playerGuid, + player, + candidate)) + { + continue; + } + + bool hasHealth = runtime.ActionOwner.Combat.HasHealth( + record.ServerGuid); + float health = hasHealth + ? runtime.ActionOwner.Combat.GetHealthPercent(record.ServerGuid) + : 1f; + if (hasHealth && health <= 0f) + continue; + + Vector3 targetWorld = AbsolutePosition(position); + Vector2 delta = new( + targetWorld.X - playerWorld.X, + targetWorld.Y - playerWorld.Y); + float distanceSquared = delta.LengthSquared(); + if (distanceSquared > maximumDistanceSquared) + continue; + + float targetHeading = MoveToMath.PositionHeading( + playerWorld, + targetWorld); + float relativeAngle = NormalizeSignedDegrees( + targetHeading - playerHeading); + int speciesId = candidate?.Properties.GetInt( + (uint)PropertyInt.CreatureType) ?? 0; + bool hasShield = candidate is not null + && objects.GetEquippedBy(candidate.ObjectId).Any(static item => + (item.Type & ItemType.Armor) != 0); + runtime.ActionOwner.TryGetHealthActivity( + record.ServerGuid, + out long healthRevision, + out double healthAge); + targets.Add(new RuntimeHostileTargetSnapshot( + record.ServerGuid, + candidate?.Name ?? string.Empty, + candidate?.WeenieClassId ?? 0u, + MathF.Sqrt(distanceSquared), + relativeAngle, + hasHealth, + health) + { + SpeciesId = speciesId, + // CreatureProfile maximum HP is appraisal data and is not yet + // a Runtime owner. Zero truthfully means unknown; MossTank's + // maxhp expressions begin matching as soon as that owner lands. + MaximumHealth = 0, + HasShield = hasShield, + Incarnation = record.Incarnation, + HealthRevision = healthRevision, + SecondsSinceHealthUpdate = healthAge, + }); + } + + return targets.Count == 0 + ? Array.Empty() + : targets.ToArray(); + } + public static uint? FindClosest(GameRuntime runtime) { ArgumentNullException.ThrowIfNull(runtime); @@ -112,4 +248,14 @@ public static class RuntimeHostileTargetQuery position.PositionY + landblockY * 192f, position.PositionZ); } + + private static float NormalizeSignedDegrees(float degrees) + { + float normalized = degrees % 360f; + if (normalized > 180f) + normalized -= 360f; + else if (normalized < -180f) + normalized += 360f; + return normalized; + } } diff --git a/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs b/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs index 37b11903..083ed01c 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeInteractionTransactionState.cs @@ -52,6 +52,15 @@ public readonly record struct RuntimeAppraisalResponseAcceptance( bool Accepted, bool FirstResponse); +public readonly record struct RuntimeItemUseCompletion( + long Revision, + uint SourceObjectId, + uint TargetObjectId, + uint WeenieError) +{ + public bool IsSuccess => Revision != 0 && WeenieError == 0u; +} + public enum RuntimeInteractionDispatchResult { Rejected, @@ -76,7 +85,9 @@ public readonly record struct RuntimeInteractionTransactionSnapshot( // cancellation resolves it — it must reach zero at teardown exactly // like HasPendingPickup. bool HasPendingUse = false, - ulong PendingUseToken = 0u) + ulong PendingUseToken = 0u, + bool AwaitingItemUseCompletion = false, + RuntimeItemUseCompletion LastItemUseCompletion = default) { public bool IsConverged => IsDisposed @@ -86,7 +97,9 @@ public readonly record struct RuntimeInteractionTransactionSnapshot( && CurrentAppraisalId == 0u && OutboundCount == 0 && !HasPendingPickup - && !HasPendingUse; + && !HasPendingUse + && !AwaitingItemUseCompletion + && LastItemUseCompletion.Revision == 0; } /// @@ -131,6 +144,7 @@ public sealed class RuntimeInteractionTransactionState : IDisposable private uint _clearEpoch; private long _revision; private long _dispatchFailureCount; + private bool _awaitingItemUseCompletion; private bool _disposed; public RuntimeInteractionTransactionState( @@ -151,6 +165,7 @@ public sealed class RuntimeInteractionTransactionState : IDisposable public long DispatchFailureCount => Interlocked.Read(ref _dispatchFailureCount); public Exception? LastDispatchFailure { get; private set; } + public RuntimeItemUseCompletion LastItemUseCompletion { get; private set; } public RuntimeInteractionTransactionSnapshot CaptureOwnership() => new( _disposed, @@ -164,7 +179,9 @@ public sealed class RuntimeInteractionTransactionState : IDisposable _pendingPickup?.Token ?? 0u, DispatchFailureCount, _pendingUse is not null, - _pendingUse?.Token ?? 0u); + _pendingUse?.Token ?? 0u, + _awaitingItemUseCompletion, + LastItemUseCompletion); public bool TryConsumeUseThrottle(long nowMs) { @@ -220,6 +237,7 @@ public sealed class RuntimeInteractionTransactionState : IDisposable reservation?.MarkDispatched(); _lastUseSourceId = serverGuid; _lastUseTargetId = 0u; + _awaitingItemUseCompletion = true; IncrementRevision(); verdict = RuntimeInteractionDispatchResult.Dispatched; } @@ -246,6 +264,7 @@ public sealed class RuntimeInteractionTransactionState : IDisposable _lastUseSourceId = sourceObjectId; _lastUseTargetId = targetObjectId; + _awaitingItemUseCompletion = true; if (incrementBusy) _inventory.IncrementBusyCount(); IncrementRevision(); @@ -264,7 +283,17 @@ public sealed class RuntimeInteractionTransactionState : IDisposable ObjectDisposedException.ThrowIf(_disposed, this); int before = _inventory.BusyCount; _inventory.CompleteUse(error); - if (_inventory.BusyCount != before) + if (_awaitingItemUseCompletion) + { + LastItemUseCompletion = new RuntimeItemUseCompletion( + LastItemUseCompletion.Revision + 1, + _lastUseSourceId, + _lastUseTargetId, + error); + _awaitingItemUseCompletion = false; + IncrementRevision(); + } + else if (_inventory.BusyCount != before) IncrementRevision(); } @@ -706,6 +735,8 @@ public sealed class RuntimeInteractionTransactionState : IDisposable || _pendingUse is not null || _lastUseSourceId != 0u || _lastUseTargetId != 0u + || _awaitingItemUseCompletion + || LastItemUseCompletion.Revision != 0 || _lastUseMs != long.MinValue / 2; // G3: an armed Use's reservation is a live busy-count reference — @@ -717,6 +748,8 @@ public sealed class RuntimeInteractionTransactionState : IDisposable _lastUseSourceId = 0u; _lastUseTargetId = 0u; + _awaitingItemUseCompletion = false; + LastItemUseCompletion = default; _awaitingAppraisalId = 0u; _currentAppraisalId = 0u; _outbound.Clear(); diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs index d354b974..6f0edef6 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerMovementState.cs @@ -202,7 +202,7 @@ public sealed class RuntimeLocalPlayerMovementState : new RuntimeMovementSnapshot( true, controller.LocalEntityId, - controller.CellPosition, + controller.CurrentCellPosition, controller.BodyVelocity, controller.IsAirborne, controller.SimTimeSeconds, diff --git a/src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs b/src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs index 9c42309b..79010dff 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeSpellCastState.cs @@ -4,6 +4,15 @@ using AcDream.Core.Spells; namespace AcDream.Runtime.Gameplay; +public readonly record struct RuntimeSpellCastCompletion( + long Revision, + uint SpellId, + uint TargetObjectId, + uint WeenieError) +{ + public bool IsSuccess => Revision != 0 && WeenieError == 0u; +} + public interface IRuntimeSpellCastOperations { uint LocalPlayerId { get; } @@ -48,6 +57,9 @@ public sealed class RuntimeSpellCastState public uint? LastRequestedSpellId { get; private set; } public uint? LastRequestedTargetId { get; private set; } + public uint? PendingSpellId { get; private set; } + public uint? PendingTargetId { get; private set; } + public RuntimeSpellCastCompletion LastCompletion { get; private set; } public event Action? StateChanged; public bool IsTargetReady(uint spellId) => @@ -130,12 +142,19 @@ public sealed class RuntimeSpellCastState _operations.DisplayMessage("You cannot cast a spell right now."); return CastRequestResult.Unavailable; } + if (PendingSpellId is not null) + { + _operations.DisplayMessage("You cannot cast a spell right now."); + return CastRequestResult.Unavailable; + } try { _operations.StopCompletely(); LastRequestedSpellId = spellId; LastRequestedTargetId = target; + PendingSpellId = spellId; + PendingTargetId = target; if (untargeted) _operations.SendUntargeted(spellId); else @@ -149,18 +168,48 @@ public sealed class RuntimeSpellCastState { LastRequestedSpellId = null; LastRequestedTargetId = null; + PendingSpellId = null; + PendingTargetId = null; throw; } StateChanged?.Invoke(); return CastRequestResult.Sent; } + /// + /// Resolve the one cast currently holding retail's shared UseDone busy + /// reference. Other item-use completions are ignored when no cast is + /// pending, so this owner cannot fabricate a cast receipt. + /// + public bool CompleteUse(uint weenieError) + { + if (PendingSpellId is not uint spellId) + return false; + + long revision = LastCompletion.Revision + 1; + LastCompletion = new RuntimeSpellCastCompletion( + revision, + spellId, + PendingTargetId ?? 0u, + weenieError); + PendingSpellId = null; + PendingTargetId = null; + StateChanged?.Invoke(); + return true; + } + public void Reset() { bool changed = LastRequestedSpellId is not null - || LastRequestedTargetId is not null; + || LastRequestedTargetId is not null + || PendingSpellId is not null + || PendingTargetId is not null + || LastCompletion.Revision != 0; LastRequestedSpellId = null; LastRequestedTargetId = null; + PendingSpellId = null; + PendingTargetId = null; + LastCompletion = default; if (changed) StateChanged?.Invoke(); } diff --git a/src/AcDream.Runtime/Session/LiveSessionController.cs b/src/AcDream.Runtime/Session/LiveSessionController.cs index 81729bcf..9e452a49 100644 --- a/src/AcDream.Runtime/Session/LiveSessionController.cs +++ b/src/AcDream.Runtime/Session/LiveSessionController.cs @@ -455,6 +455,7 @@ public sealed class LiveSessionController /// private int _createsSinceCharacterList; private LiveSessionCharacterSelection? _activeSelection; + private uint _nextLoginCharacterId; private Action? _autoSaveTickHook; private Action? _preLogoffFlushHook; @@ -516,6 +517,47 @@ public sealed class LiveSessionController get { lock (_gate) return new RuntimeGenerationToken(_generation); } } + /// + /// UtilityBelt-compatible one-shot login choice. The id is retained across + /// the world-generation reset performed by character logoff, then consumed + /// only after the selected character successfully enters the world. + /// + public uint NextLoginCharacterId + { + get { lock (_gate) return _nextLoginCharacterId; } + } + + public bool TrySetNextLogin(uint characterId) + { + lock (_gate) + { + if (_disposed + || _disposeRequested + || _scope is null + || characterId == 0u + || !CharacterSelectionState.View.TryGet( + characterId, + out RuntimeCharacterSelectionEntry character) + || !character.CanEnter) + { + return false; + } + _nextLoginCharacterId = characterId; + return true; + } + } + + public bool ClearNextLogin() + { + lock (_gate) + { + if (_disposed) + return false; + _nextLoginCharacterId = 0u; + return true; + } + } + /// /// MUST-FIX 1 (Campaign OP OP1 review fix, 2026-08-11 — mechanism lens /// finding): reaches retail's CPlayerModule::UseTime (the 480 s @@ -1339,6 +1381,21 @@ public sealed class LiveSessionController if (_operations.GetServerInfo(session) is { } serverInfo) CharacterSelectionState.ApplyWorldName(serverInfo.WorldName); + // UtilityBelt LoaderLogin parity: after a character logout, the + // loader sees character select and immediately invokes retail's + // normal LogOnCharacter route for the remembered GUID. Reuse this + // controller's exact highlight/Enter transaction so host binding, + // command activation and lifecycle publication stay canonical. + uint nextLogin = _nextLoginCharacterId; + if (nextLogin != 0u + && CharacterSelectionState.TryHighlight(nextLogin)) + { + RuntimeCommandResult entered = EnterSelectedCore(); + if (entered.Status == RuntimeCommandStatus.Accepted) + _nextLoginCharacterId = 0u; + return entered; + } + Console.WriteLine( "live: character logoff complete — returned to character " + "select (session connected)"); diff --git a/src/AcDream.Runtime/packages.win-x64.lock.json b/src/AcDream.Runtime/packages.win-x64.lock.json index 0b1f001d..98098838 100644 --- a/src/AcDream.Runtime/packages.win-x64.lock.json +++ b/src/AcDream.Runtime/packages.win-x64.lock.json @@ -2,23 +2,6 @@ "version": 2, "dependencies": { "net10.0": { - "BCnEncoder.Net.ImageSharp": { - "type": "Direct", - "requested": "[1.1.2, )", - "resolved": "1.1.2", - "contentHash": "qUi8L+bNfHJii95BMBcV6MhBchkKU2VV6sd6D1yyzgm77YhMt+aFT0keh5uf70bvTsRrq/ZKQnE4UQScNU6XAA==", - "dependencies": { - "BCnEncoder.Net": "2.2.0", - "CommunityToolkit.HighPerformance": "8.4.0", - "SixLabors.ImageSharp": "3.1.7" - } - }, - "SixLabors.ImageSharp": { - "type": "Direct", - "requested": "[3.1.12, )", - "resolved": "3.1.12", - "contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==" - }, "Autofac": { "type": "Transitive", "resolved": "8.4.0", @@ -213,6 +196,14 @@ "resolved": "0.1.1", "contentHash": "QEti4O7dwRcOb9zbnLuudSrt2IT61OYjq0R7lcJb+EzUm5N6djOVGU/cp+FZIZzJUnaFIntwrpwiuRvhpS7ZHg==" }, + "acdream.content": { + "type": "Project", + "dependencies": { + "AcDream.Core": "[1.0.0, )", + "BCnEncoder.Net.ImageSharp": "[1.1.2, )", + "SixLabors.ImageSharp": "[3.1.12, )" + } + }, "acdream.core": { "type": "Project", "dependencies": { @@ -224,6 +215,15 @@ "StbImageSharp": "[2.30.16, )" } }, + "acdream.core.net": { + "type": "Project", + "dependencies": { + "AcDream.Core": "[1.0.0, )" + } + }, + "acdream.platform": { + "type": "Project" + }, "acdream.plugin.abstractions": { "type": "Project" }, @@ -236,6 +236,17 @@ "CommunityToolkit.HighPerformance": "8.4.0" } }, + "BCnEncoder.Net.ImageSharp": { + "type": "CentralTransitive", + "requested": "[1.1.2, )", + "resolved": "1.1.2", + "contentHash": "qUi8L+bNfHJii95BMBcV6MhBchkKU2VV6sd6D1yyzgm77YhMt+aFT0keh5uf70bvTsRrq/ZKQnE4UQScNU6XAA==", + "dependencies": { + "BCnEncoder.Net": "2.2.0", + "CommunityToolkit.HighPerformance": "8.4.0", + "SixLabors.ImageSharp": "3.1.7" + } + }, "Chorizite.Core": { "type": "CentralTransitive", "requested": "[0.0.18, )", @@ -279,6 +290,12 @@ "resolved": "4.0.2", "contentHash": "Vehq4uNYtURe/OnHEpWGvMgrvr5Vou7oZLdn3BuEH5FSCeHXDpNJtpzWoqywXsSvCTuiv0I65mZDRnJSeUvisA==" }, + "SixLabors.ImageSharp": { + "type": "CentralTransitive", + "requested": "[3.1.12, )", + "resolved": "3.1.12", + "contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A==" + }, "StbImageSharp": { "type": "CentralTransitive", "requested": "[2.30.16, )", diff --git a/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs b/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs index e7047018..c41e2791 100644 --- a/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs +++ b/tests/AcDream.App.Tests/Input/DispatcherMovementInputSourceTests.cs @@ -34,6 +34,21 @@ public sealed class DispatcherMovementInputSourceTests Assert.False(captured.Run); } + [Fact] + public void CommandInputIsMarkedPersistentWithoutChangingStoredSnapshot() + { + using var movement = new RuntimeLocalPlayerMovementState(); + var command = new MovementInput(TurnRight: true); + movement.SetCommandInput(command); + var source = new DispatcherMovementInputSource(movement); + + MovementInput captured = source.Capture(); + + Assert.True(captured.TurnRight); + Assert.True(captured.IsPersistentCommand); + Assert.Equal(command, movement.CommandInput); + } + [Fact] public void RetainedKeyboardCaptureSilencesHeldKeysButDoesNotCancelAutorun() { diff --git a/tests/AcDream.App.Tests/Platform/Win32GlfwActiveWindowGuardTests.cs b/tests/AcDream.App.Tests/Platform/Win32GlfwActiveWindowGuardTests.cs new file mode 100644 index 00000000..58c8694e --- /dev/null +++ b/tests/AcDream.App.Tests/Platform/Win32GlfwActiveWindowGuardTests.cs @@ -0,0 +1,46 @@ +using AcDream.App.Platform; + +namespace AcDream.App.Tests.Platform; + +public sealed class Win32GlfwActiveWindowGuardTests +{ + [Fact] + public void CurrentProcessWindowRemainsVisibleToGlfw() + { + nint window = (nint)0x1234; + + Assert.Equal( + window, + Win32GlfwActiveWindowGuard.AcceptWindow( + window, + ownerProcessId: 47, + currentProcessId: 47)); + } + + [Fact] + public void ForeignProcessWindowBecomesGlfwsExistingNoWindowPath() + { + Assert.Equal( + 0, + Win32GlfwActiveWindowGuard.AcceptWindow( + (nint)0x1234, + ownerProcessId: 48, + currentProcessId: 47)); + } + + [Theory] + [InlineData(0, 47, 47)] + [InlineData(0x1234, 0, 47)] + public void MissingOrUnownedWindowIsRejected( + long window, + uint ownerProcessId, + uint currentProcessId) + { + Assert.Equal( + 0, + Win32GlfwActiveWindowGuard.AcceptWindow( + (nint)window, + ownerProcessId, + currentProcessId)); + } +} diff --git a/tests/AcDream.App.Tests/Plugins/AppAutomationSurfaceTests.cs b/tests/AcDream.App.Tests/Plugins/AppAutomationSurfaceTests.cs new file mode 100644 index 00000000..68d3cb5e --- /dev/null +++ b/tests/AcDream.App.Tests/Plugins/AppAutomationSurfaceTests.cs @@ -0,0 +1,277 @@ +using AcDream.App.Plugins; +using AcDream.Core.Chat; +using AcDream.Core.Items; +using AcDream.Core.Physics; +using AcDream.Core.Selection; +using AcDream.Core.Spells; +using AcDream.Plugin.Abstractions; +using AcDream.Runtime.Gameplay; +using System.Numerics; + +namespace AcDream.App.Tests.Plugins; + +public sealed class AppAutomationSurfaceTests +{ + [Fact] + public void ProjectileDebugSamplesAreDetachedValidatedAndClearedOnUnbind() + { + using var surface = new AppAutomationSurface(); + PluginProjectileDebugSample[] source = + [ + new(new Vector3(1f, 2f, 3f), true, 0.4f), + new(new Vector3(float.NaN, 0f, 0f), false, 0.4f), + ]; + + surface.Projectiles.ShowDebugSamples(source); + source[0] = default; + + PluginProjectileDebugSample sample = Assert.Single( + surface.CaptureProjectileDebugSamples()); + Assert.Equal(new Vector3(1f, 2f, 3f), sample.WorldPosition); + Assert.True(sample.IsClear); + + surface.Unbind(); + Assert.Empty(surface.CaptureProjectileDebugSamples()); + } + + [Fact] + public void SelectionAutomationUsesTheBoundCanonicalActionRoute() + { + using var surface = new AppAutomationSurface(); + var actions = new List(); + surface.BindSelectionActions(action => + { + actions.Add(action); + return true; + }); + + Assert.True(surface.Selection.Execute( + PluginSelectionAction.PreviousSelection)); + Assert.True(surface.Selection.Execute( + PluginSelectionAction.NextPlayer)); + Assert.Equal( + [ + PluginSelectionAction.PreviousSelection, + PluginSelectionAction.NextPlayer, + ], + actions); + } + + [Theory] + [InlineData((uint)ItemType.MeleeWeapon, 0u, PluginObjectClass.MeleeWeapon)] + [InlineData((uint)ItemType.Armor, 0u, PluginObjectClass.Armor)] + [InlineData((uint)ItemType.Creature, 0x10u, PluginObjectClass.Monster)] + [InlineData((uint)ItemType.Creature, 0u, PluginObjectClass.Npc)] + [InlineData((uint)ItemType.Creature, 0x04000010u, PluginObjectClass.CombatPet)] + [InlineData((uint)ItemType.Creature, 0x8u, PluginObjectClass.Player)] + [InlineData((uint)ItemType.Misc, 0x200u, PluginObjectClass.Vendor)] + [InlineData((uint)ItemType.Misc, 0x1000u, PluginObjectClass.Door)] + public void ObjectClassProjectionMatchesVirindiPriority( + uint itemType, + uint publicFlags, + PluginObjectClass expected) + { + var item = new ClientObject + { + ObjectId = 1u, + Type = (ItemType)itemType, + PublicWeenieBitfield = publicFlags, + }; + + Assert.Equal(expected, AppAutomationSurface.ClassifyObject(item)); + } + + [Fact] + public void NavigationProjectionUsesVtankMapCoordinatesAndCompassHeading() + { + PluginNavigationPosition center = + AppAutomationSurface.ProjectNavigationPosition(new Position( + 0x7F7F0001u, + new Vector3(84f, 84f, 240f), + Quaternion.Identity)); + + Assert.Equal(0d, center.EastWest, 8); + Assert.Equal(0d, center.NorthSouth, 8); + Assert.Equal(1d, center.Elevation, 8); + Assert.Equal(0f, center.HeadingDegrees, 4); + Assert.True(center.IsOutdoor); + + PluginNavigationPosition nextBlock = + AppAutomationSurface.ProjectNavigationPosition(new Position( + 0x80800041u, + new Vector3(84f, 84f, 0f), + Quaternion.Identity)); + + Assert.Equal(0.8d, nextBlock.EastWest, 8); + Assert.Equal(0.8d, nextBlock.NorthSouth, 8); + Assert.False(nextBlock.IsOutdoor); + } + + [Fact] + public void ChatCapture_isOrderedCursorBasedAndDetachesAcrossSessions() + { + using var first = GameRuntimeTestFactory.Create(); + using var second = GameRuntimeTestFactory.Create(); + using var surface = new AppAutomationSurface(); + surface.Bind( + first, + first.CharacterOwner, + first.ActionOwner.SpellCast); + + first.CommunicationOwner.AddText( + "You cast Imperil Other VII on Olthoi.", + RetailLogTextType.Magic); + PluginChatMessage one = Assert.Single(surface.CaptureMessages(0)); + Assert.Equal("You cast Imperil Other VII on Olthoi.", one.Text); + Assert.Empty(surface.CaptureMessages(one.Sequence)); + + surface.Bind( + second, + second.CharacterOwner, + second.ActionOwner.SpellCast); + first.CommunicationOwner.AddText( + "stale first-session line", + RetailLogTextType.Magic); + second.CommunicationOwner.AddText( + "You cast Fester Other VII on Olthoi.", + RetailLogTextType.Magic); + + PluginChatMessage two = Assert.Single( + surface.CaptureMessages(one.Sequence)); + Assert.True(two.Sequence > one.Sequence); + Assert.Equal("You cast Fester Other VII on Olthoi.", two.Text); + Assert.Equal(1, second.CommunicationOwner.SubscriberCount); + + surface.Dispose(); + Assert.Equal(0, second.CommunicationOwner.SubscriberCount); + } + + [Fact] + public void InventoryCompletionProjectsTheCanonicalRequestReceipt() + { + using var runtime = GameRuntimeTestFactory.Create(); + using var surface = new AppAutomationSurface(); + surface.Bind(runtime, runtime.CharacterOwner, runtime.ActionOwner.SpellCast); + ClientObjectTable objects = runtime.InventoryOwner.Objects; + const uint itemId = 0x50000123u; + objects.AddOrUpdate(new ClientObject + { + ObjectId = itemId, + Name = "Stack", + StackSize = 10, + StackSizeMax = 100, + }); + + Assert.True(runtime.InventoryOwner.Transactions.TryDispatch( + InventoryRequestKind.Merge, + itemId, + static () => true)); + Assert.True(objects.UpdateStackSize(itemId, 9, 0)); + + PluginInventoryCompletion completion = + surface.Items.LastInventoryCompletion; + Assert.True(completion.Revision > 0); + Assert.Equal(PluginInventoryCommandKind.Merge, completion.Kind); + Assert.Equal(itemId, completion.SourceObjectId); + Assert.True(completion.IsSuccess); + } + + [Fact] + public void RecoveryClearsExactlyOneCanonicalBusyReference() + { + using var runtime = GameRuntimeTestFactory.Create(); + using var surface = new AppAutomationSurface(); + surface.Bind(runtime, runtime.CharacterOwner, runtime.ActionOwner.SpellCast); + runtime.InventoryOwner.Transactions.IncrementBusyCount(); + runtime.InventoryOwner.Transactions.IncrementBusyCount(); + + PluginRecoveryResult first = surface.Recovery.ClearOneBusyReference(); + PluginRecoveryResult second = surface.Recovery.ClearOneBusyReference(); + PluginRecoveryResult alreadyClear = + surface.Recovery.ClearOneBusyReference(); + + Assert.True(first.Accepted); + Assert.Equal((2, 1), (first.PreviousCount, first.CurrentCount)); + Assert.Equal((1, 0), (second.PreviousCount, second.CurrentCount)); + Assert.Equal((0, 0), + (alreadyClear.PreviousCount, alreadyClear.CurrentCount)); + Assert.Equal(0, runtime.InventoryOwner.Transactions.BusyCount); + } + + [Fact] + public void EnchantmentLedgerSharesReportedAndConfirmedLocalDurationCasts() + { + var operations = new SpellOperations(); + using var runtime = GameRuntimeTestFactory.Create(spellCast: operations); + runtime.CharacterOwner.InstallSpellMetadata(SpellTable.Create([DurationSpell()])); + runtime.CharacterOwner.Spellbook.OnSpellLearned(42u); + using var surface = new AppAutomationSurface(); + surface.Bind(runtime, runtime.CharacterOwner, runtime.ActionOwner.SpellCast); + + Assert.True(surface.Enchantments.ReportCast(100u, 42u, 30d)); + PluginTrackedEnchantment reported = Assert.Single( + surface.Enchantments.Capture(100u)); + Assert.Equal(7u, reported.Family); + Assert.Equal(350, reported.Quality); + Assert.InRange(reported.SecondsRemaining, 29d, 30d); + + runtime.ActionOwner.Selection.Select( + 200u, + SelectionChangeSource.Plugin); + Assert.Equal( + CastRequestResult.Sent, + runtime.ActionOwner.SpellCast.Cast(42u)); + Assert.True(runtime.ActionOwner.SpellCast.CompleteUse(0u)); + + Assert.True(surface.Magic.LastCompletion.IsSuccess); + PluginTrackedEnchantment local = Assert.Single( + surface.Enchantments.Capture(200u)); + Assert.Equal(42u, local.SpellId); + Assert.InRange(local.SecondsRemaining, 59d, 60d); + + surface.Unbind(); + Assert.Empty(surface.Enchantments.Capture(100u)); + Assert.Empty(surface.Enchantments.Capture(200u)); + } + + private static SpellMetadata DurationSpell() => new( + 42u, + "Fire Vulnerability Other VII", + "Life Magic", + 7u, + 0u, + string.Empty, + 60f, + 10, + true, + false, + string.Empty, + 0, + 350, + 0u, + 7, + false, + true, + false, + 0f, + 0u, + 0u, + 1u, + 0); + + private sealed class SpellOperations : IRuntimeSpellCastOperations + { + public uint LocalPlayerId => 1u; + public bool CanSend => true; + public bool HasRequiredComponents(uint spellId) => true; + public bool IsTargetCompatible( + uint targetId, + SpellMetadata spell, + bool showMessage) => true; + public void StopCompletely() { } + public void SendUntargeted(uint spellId) { } + public void SendTargeted(uint targetId, uint spellId) { } + public void DisplayMessage(string message) { } + public void IncrementBusy() { } + } +} diff --git a/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs b/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs index 23e84896..fc471f43 100644 --- a/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs +++ b/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs @@ -1,5 +1,6 @@ using AcDream.App.Plugins; using AcDream.App.UI; +using AcDream.Plugin.Abstractions; namespace AcDream.App.Tests.Plugins; @@ -32,6 +33,8 @@ public class BufferedUiRegistryTests var element = new UiPanel(); root.AddChild(element); registry.CompleteMount(pending, root, element); + bool windowRemoved = false; + registry.CompleteWindowMount(pending, () => windowRemoved = true); Assert.Contains(element, root.Children); Assert.Equal(1, registry.RegistrationCount); @@ -40,5 +43,92 @@ public class BufferedUiRegistryTests Assert.DoesNotContain(element, root.Children); Assert.Equal(0, registry.RegistrationCount); + Assert.True(windowRemoved); + } + + [Fact] + public void FirstClassPanelCarriesManifestOwnerAndStableWindowIdentity() + { + var registry = new BufferedUiRegistry(); + var descriptor = new PluginPanelDescriptor("main", "MossTank") + { + IconText = "MT", + StartVisible = false, + }; + + registry.RegisterPanel( + new PluginUiOwner("acdream.mosstank", "MossTank"), + descriptor, + "mosstank.xml", + new object()); + + BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain()); + Assert.Equal("acdream.mosstank", pending.Owner.Id); + Assert.Same(descriptor, pending.Descriptor); + Assert.Equal("plugin:acdream.mosstank:main", pending.WindowName); + Assert.False(pending.Descriptor.StartVisible); + } + + [Fact] + public void InlinePanelContentHasAnIndependentlyRemovableLifetime() + { + var registry = new BufferedUiRegistry(); + IDisposable token = registry.RegisterPanelContent( + new PluginUiOwner("acdream.mosstank", "MossTank"), + new PluginPanelDescriptor("meta-status", "Status"), + "", + new object()); + + BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain()); + Assert.Equal("", pending.MarkupContent); + Assert.Equal("plugin:acdream.mosstank:meta-status", pending.WindowName); + + token.Dispose(); + Assert.Equal(0, registry.RegistrationCount); + } + + [Fact] + public void LateWindowPublicationCleansUpAfterConcurrentDisposal() + { + var registry = new BufferedUiRegistry(); + IDisposable token = registry.RegisterMarkupPanel("late.xml", new object()); + BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain()); + token.Dispose(); + bool cleaned = false; + + registry.CompleteWindowMount(pending, () => cleaned = true); + + Assert.True(cleaned); + Assert.Equal(0, registry.RegistrationCount); + } + + [Fact] + public void ScopedViewsExposeOnlyTheirOwnNamedControls() + { + var registry = new BufferedUiRegistry(); + var owner = new PluginUiOwner("acdream.mosstank", "MossTank"); + registry.RegisterPanelContent( + owner, + new PluginPanelDescriptor("meta", "Status View"), + "", + new object()); + BufferedUiRegistry.Pending pending = Assert.Single(registry.Drain()); + var root = new UiRoot(); + var panel = new UiPanel(); + var button = new UiSimpleButton { Name = "Action", Text = "Old" }; + panel.AddChild(button); + root.AddChild(panel); + registry.CompleteMount(pending, root, panel); + + Assert.True(registry.ViewExists(owner, "Status View")); + Assert.True(registry.IsViewVisible(owner, "meta")); + Assert.True(registry.ControlExists(owner, "Status View", "Action")); + Assert.True(registry.SetControlLabel(owner, "Status View", "Action", "New")); + Assert.Equal("New", button.Text); + Assert.True(registry.SetControlVisible( + owner, "Status View", "Action", false)); + Assert.False(button.Visible); + Assert.False(registry.ViewExists( + new PluginUiOwner("another.plugin", "Other"), "Status View")); } } diff --git a/tests/AcDream.App.Tests/Plugins/ExternalRenderPackPackageLifecycleTests.cs b/tests/AcDream.App.Tests/Plugins/ExternalRenderPackPackageLifecycleTests.cs index 799c96f1..2e23874f 100644 --- a/tests/AcDream.App.Tests/Plugins/ExternalRenderPackPackageLifecycleTests.cs +++ b/tests/AcDream.App.Tests/Plugins/ExternalRenderPackPackageLifecycleTests.cs @@ -526,20 +526,29 @@ public sealed class ExternalRenderPackPackageLifecycleTests private static string FixtureAssemblyPath() { + const string projectName = "AcDream.Plugin.Tests.Fixtures.HostPlugin"; + string colocated = Path.Combine(AppContext.BaseDirectory, projectName + ".dll"); + if (File.Exists(colocated)) + return colocated; + string configuration = new DirectoryInfo(AppContext.BaseDirectory) .Parent!.Name; return Path.Combine( FindRepoRoot(), "tests", - "AcDream.Plugin.Tests.Fixtures.HostPlugin", + projectName, "bin", configuration, "net10.0", - "AcDream.Plugin.Tests.Fixtures.HostPlugin.dll"); + projectName + ".dll"); } private static string FixtureAssemblyPath(string projectName) { + string colocated = Path.Combine(AppContext.BaseDirectory, projectName + ".dll"); + if (File.Exists(colocated)) + return colocated; + string configuration = new DirectoryInfo(AppContext.BaseDirectory) .Parent!.Name; return Path.Combine( diff --git a/tests/AcDream.App.Tests/Plugins/FilePluginStorageTests.cs b/tests/AcDream.App.Tests/Plugins/FilePluginStorageTests.cs new file mode 100644 index 00000000..28e99a53 --- /dev/null +++ b/tests/AcDream.App.Tests/Plugins/FilePluginStorageTests.cs @@ -0,0 +1,35 @@ +using AcDream.App.Plugins; + +namespace AcDream.App.Tests.Plugins; + +public sealed class FilePluginStorageTests +{ + [Fact] + public void WriteReadReplaceAndDeleteStayUnderConfiguredRoot() + { + string root = Path.Combine( + Path.GetTempPath(), + $"acdream-plugin-storage-{Guid.NewGuid():N}"); + try + { + var storage = new FilePluginStorage(root); + storage.WriteText("plugin/profile.json", "one"); + storage.WriteText("plugin/imports/route.nav", "nav"); + Assert.Equal("one", storage.ReadText("plugin/profile.json")); + storage.WriteText("plugin/profile.json", "two"); + Assert.Equal("two", storage.ReadText("plugin/profile.json")); + Assert.Equal( + ["plugin/imports/route.nav"], + storage.List("plugin/imports")); + Assert.True(storage.Delete("plugin/profile.json")); + Assert.Null(storage.ReadText("plugin/profile.json")); + Assert.Throws(() => + storage.WriteText("../escape.json", "bad")); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } +} diff --git a/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs b/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs index 6e039746..451c8aa2 100644 --- a/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs +++ b/tests/AcDream.App.Tests/Plugins/GraphicalPluginSessionTests.cs @@ -232,6 +232,10 @@ public sealed class GraphicalPluginSessionTests "fixture-panel.xml", panel.MarkupPath, StringComparison.Ordinal); + Assert.Equal(FixtureId, panel.Owner.Id); + Assert.Equal("Host fixture", panel.Owner.DisplayName); + Assert.Equal("fixture-panel", panel.Descriptor.WindowId); + Assert.Equal("Host fixture", panel.Descriptor.Title); Assert.Equal( "AcDream.Plugin.Tests.Fixtures.HostPlugin", panel.Binding.GetType().Assembly.GetName().Name); @@ -271,6 +275,11 @@ public sealed class GraphicalPluginSessionTests private static string FixtureAssemblyPath() { + string fileName = "AcDream.Plugin.Tests.Fixtures.HostPlugin.dll"; + string colocated = Path.Combine(AppContext.BaseDirectory, fileName); + if (File.Exists(colocated)) + return colocated; + string configuration = new DirectoryInfo(AppContext.BaseDirectory) .Parent!.Name; string root = FindRepoRoot(AppContext.BaseDirectory); @@ -281,7 +290,7 @@ public sealed class GraphicalPluginSessionTests "bin", configuration, "net10.0", - "AcDream.Plugin.Tests.Fixtures.HostPlugin.dll"); + fileName); } private static string FindRepoRoot(string start) diff --git a/tests/AcDream.App.Tests/Plugins/LocalPluginPeerRegistryTests.cs b/tests/AcDream.App.Tests/Plugins/LocalPluginPeerRegistryTests.cs new file mode 100644 index 00000000..63ff920e --- /dev/null +++ b/tests/AcDream.App.Tests/Plugins/LocalPluginPeerRegistryTests.cs @@ -0,0 +1,73 @@ +using AcDream.App.Plugins; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.Tests.Plugins; + +public sealed class LocalPluginPeerRegistryTests +{ + [Fact] + public void PublishesRemoteClientsIgnoresSelfAndExpiresStaleHeartbeat() + { + string root = Path.Combine( + Path.GetTempPath(), + $"acdream-plugin-peers-{Guid.NewGuid():N}"); + var time = new ManualTimeProvider( + new DateTimeOffset(2026, 8, 27, 12, 0, 0, TimeSpan.Zero)); + try + { + using var first = new LocalPluginPeerRegistry( + root, + time, + Guid.Parse("11111111-1111-1111-1111-111111111111")); + using var second = new LocalPluginPeerRegistry( + root, + time, + Guid.Parse("22222222-2222-2222-2222-222222222222")); + first.Publish(Client(first.ClientId, 10u, "Alpha", ["one"])); + second.Publish(Client(second.ClientId, 20u, "Beta", ["two"])); + + PluginNetworkClient remote = Assert.Single( + first.CaptureRemoteClients()); + Assert.Equal(second.ClientId, remote.ClientId); + Assert.Equal("Beta", remote.Name); + Assert.Equal(["two"], remote.Tags); + Assert.Equal(33.5d, remote.Position.EastWest); + + time.Advance(LocalPluginPeerRegistry.StaleAfter + + TimeSpan.FromMilliseconds(1)); + Assert.Empty(first.CaptureRemoteClients()); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + private static PluginNetworkClient Client( + uint clientId, + uint playerId, + string name, + IReadOnlyList tags) => new( + clientId, + playerId, + name, + "Coldeve", + new PluginNavigationPosition( + 0x7F7F0001u, 33.5d, -72.8d, 1d, 90f, true), + tags, + 90u, + 70u, + 80u, + 100u, + 100u, + 100u, + 90f); + + private sealed class ManualTimeProvider(DateTimeOffset utcNow) : TimeProvider + { + private DateTimeOffset _utcNow = utcNow; + public override DateTimeOffset GetUtcNow() => _utcNow; + public void Advance(TimeSpan elapsed) => _utcNow += elapsed; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/LinuxPlatformBoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/LinuxPlatformBoundaryTests.cs index 23547a96..f6f7cb74 100644 --- a/tests/AcDream.App.Tests/Rendering/LinuxPlatformBoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LinuxPlatformBoundaryTests.cs @@ -109,7 +109,7 @@ public sealed class LinuxPlatformBoundaryTests } [Fact] - public void SmokePluginCopyUsesRidAwarePortableBuildAndPublishPaths() + public void ShippedPluginCopiesUseResolvedTargetPathsForBuildAndPublish() { string project = File.ReadAllText(Path.Combine( AppSourceRoot(), @@ -119,8 +119,16 @@ public sealed class LinuxPlatformBoundaryTests Assert.Contains("$(RuntimeIdentifier)", project, StringComparison.Ordinal); Assert.Contains("$(OutputPath)plugins/", project, StringComparison.Ordinal); Assert.Contains("$(PublishDir)plugins/", project, StringComparison.Ordinal); + Assert.Equal( + 4, + project.Split("Targets=\"GetTargetPath\"", StringSplitOptions.None) + .Length - 1); + Assert.Contains( + "../AcDream.Plugins.MossTank/mosstank.xml", + project, + StringComparison.Ordinal); Assert.DoesNotContain( - @"bin\$(Configuration)\net10.0", + "/bin/$(Configuration)", project, StringComparison.Ordinal); } diff --git a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs index 10902a19..17477cf5 100644 --- a/tests/AcDream.App.Tests/RuntimeOptionsTests.cs +++ b/tests/AcDream.App.Tests/RuntimeOptionsTests.cs @@ -167,6 +167,22 @@ public sealed class RuntimeOptionsTests Assert.False(opts.ExactAutomationFramebuffer); Assert.False(opts.UiProbeEnabled); Assert.False(opts.HasLiveCredentials); + Assert.Empty(opts.PluginTags); + } + + [Fact] + public void PluginPeerTagsAreParsedOnceBoundedAndCaseInsensitive() + { + string oversized = new('x', 129); + RuntimeOptions options = RuntimeOptions.Parse( + AnyDatDir, + Env(new() + { + ["ACDREAM_PLUGIN_TAGS"] = + $" healer,Leader,HEALER,,{oversized}, scout ", + })); + + Assert.Equal(["healer", "Leader", "scout"], options.PluginTags); } [Fact] diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index f03c7123..a0c25a99 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -40,6 +40,7 @@ public sealed class ItemInteractionControllerTests public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items, uint AlternateCurrencyId)> BuyAlls = new(); public bool SendBuyAllSucceeds = true; public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items)> Sells = new(); + public readonly List<(uint ToolGuid, IReadOnlyList ItemGuids)> Salvages = new(); public bool SendSellSucceeds = true; public readonly List Toasts = new(); public readonly List SystemMessages = new(); @@ -133,7 +134,12 @@ public sealed class ItemInteractionControllerTests }, interfaceText: (text, type) => InterfaceTexts.Add((text, type)), sendStackableMerge: (source, target, amount) => - Merges.Add((source, target, amount))); + Merges.Add((source, target, amount)), + sendSalvage: (tool, items) => + { + Salvages.Add((tool, items.ToArray())); + return true; + }); } public ItemInteractionController Controller { get; } @@ -171,6 +177,86 @@ public sealed class ItemInteractionControllerTests Assert.Empty(h.UseWithTarget); } + [Fact] + public void AutomationApply_dispatchesDirectlyWithoutInstallingTargetMode() + { + var h = new Harness(); + const uint source = 0x50000A21u; + h.AddContained(source, item => + { + item.Useability = HealthKitUseability; + item.TargetType = (uint)ItemType.Creature; + }); + + Assert.True(h.Controller.TryApplyItem(source, Player)); + + Assert.Equal(new[] { (source, Player) }, h.UseWithTarget); + Assert.False(h.Controller.IsAnyTargetModeActive); + Assert.Equal(1, h.Controller.BusyCount); + } + + [Fact] + public void AutomationApply_reportsRefusalWhenTargetIsIncompatible() + { + var h = new Harness(); + const uint source = 0x50000A21u; + const uint coat = 0x50000A22u; + h.AddContained(source, item => + { + item.Useability = HealthKitUseability; + item.TargetType = (uint)ItemType.Creature; + }); + h.AddContained(coat, item => item.Type = ItemType.Armor); + + Assert.False(h.Controller.TryApplyItem(source, coat)); + + Assert.Empty(h.UseWithTarget); + Assert.Equal(0, h.Controller.BusyCount); + } + + [Fact] + public void AutomationUse_dispatchesOnlyAnOrdinaryWireUse() + { + var h = new Harness(); + const uint item = 0x50000A23u; + h.AddContained(item, candidate => + candidate.Useability = ItemUseability.Contained); + + Assert.True(h.Controller.TryUseItemForAutomation(item)); + + Assert.Equal(new[] { item }, h.Uses); + Assert.Equal(1, h.Controller.BusyCount); + } + + [Fact] + public void AutomationUse_refusesTargetedItemInsteadOfOpeningModalCursor() + { + var h = new Harness(); + const uint item = 0x50000A24u; + h.AddContained(item, candidate => + candidate.Useability = HealthKitUseability); + + Assert.False(h.Controller.TryUseItemForAutomation(item)); + + Assert.Empty(h.Uses); + Assert.False(h.Controller.IsAnyTargetModeActive); + Assert.Equal(0, h.Controller.BusyCount); + } + + [Fact] + public void AutomationAppraisalUsesCanonicalOwnerWithoutChangingSelectionMode() + { + var h = new Harness(); + const uint item = 0x50000A25u; + h.AddContained(item); + + Assert.True(h.Controller.TryAppraiseForAutomation(item)); + + Assert.Equal(new[] { item }, h.Examines); + Assert.False(h.Controller.IsAnyTargetModeActive); + Assert.Equal(1, h.Controller.BusyCount); + } + [Fact] public void ResetSession_RetryNotifiesEveryStateObserver() { @@ -2085,6 +2171,110 @@ public sealed class ItemInteractionControllerTests Assert.False(h.Controller.TryGetPendingInventoryRequest(out _)); } + [Fact] + public void AutomationMoveUsesWholeOrExactPartialRetailRequest() + { + var whole = new Harness(); + const uint wholeItem = 0x50000A32u; + whole.AddContained(wholeItem, item => item.StackSize = 10); + + Assert.True(whole.Controller.TryMoveItemForAutomation( + wholeItem, Player, amount: 0u, placement: 7)); + Assert.Equal(new[] { (wholeItem, Player, 7) }, whole.Puts); + Assert.True(whole.Controller.TryGetPendingInventoryRequest(out var put)); + Assert.Equal(InventoryRequestKind.PutInContainer, put.Kind); + + var partial = new Harness(); + const uint partialItem = 0x50000A33u; + partial.AddContained(partialItem, item => item.StackSize = 10); + + Assert.True(partial.Controller.TryMoveItemForAutomation( + partialItem, Player, amount: 2u, placement: 3)); + Assert.Equal( + new[] { (partialItem, Player, 3u, 2u) }, + partial.SplitPuts); + Assert.True(partial.Controller.TryGetPendingInventoryRequest(out var split)); + Assert.Equal(InventoryRequestKind.SplitToContainer, split.Kind); + } + + [Fact] + public void AutomationMergeUsesRetailPlannerAndSharedGate() + { + var h = new Harness(); + const uint source = 0x50000A34u; + const uint target = 0x50000A35u; + h.AddContained(source, item => + { + item.WeenieClassId = 77u; + item.StackSize = 8; + item.StackSizeMax = 10; + }); + h.AddContained(target, item => + { + item.WeenieClassId = 77u; + item.StackSize = 7; + item.StackSizeMax = 10; + }); + + Assert.True(h.Controller.TryMergeItemsForAutomation(source, target)); + + Assert.Equal(new[] { (source, target, 3u) }, h.Merges); + Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending)); + Assert.Equal(InventoryRequestKind.Merge, pending.Kind); + Assert.False(h.Controller.TryMoveItemForAutomation(source, Player)); + Assert.Single(h.Merges); + } + + [Fact] + public void AutomationDropAndGivePreserveExactStackAmounts() + { + var drop = new Harness(); + const uint dropItem = 0x50000A36u; + drop.AddContained(dropItem, item => item.StackSize = 10); + + Assert.True(drop.Controller.TryDropItemForAutomation(dropItem, 2u)); + Assert.Equal(new[] { (dropItem, 2u) }, drop.SplitDrops); + Assert.Empty(drop.Drops); + + var give = new Harness(); + const uint giveItem = 0x50000A37u; + const uint recipient = 0x70000A38u; + give.AddContained(giveItem, item => item.StackSize = 10); + give.Objects.AddOrUpdate(new ClientObject + { + ObjectId = recipient, + Name = "Recipient", + Type = ItemType.Creature, + }); + + Assert.True(give.Controller.TryGiveItemForAutomation( + giveItem, recipient, 4u)); + Assert.Equal(new[] { (recipient, giveItem, 4u) }, give.Gives); + } + + [Fact] + public void AutomationSalvageRequiresRetailToolAndSuitableOwnedItems() + { + var h = new Harness(); + const uint tool = 0x50000A40u; + const uint source = 0x50000A41u; + h.AddContained(tool, item => item.Type = ItemType.TinkeringTool); + h.AddContained(source, item => + { + item.MaterialType = 12u; + item.Structure = 50; + }); + + Assert.True(h.Controller.TrySalvageItemsForAutomation(tool, [source])); + Assert.Single(h.Salvages); + Assert.Equal(tool, h.Salvages[0].ToolGuid); + Assert.Equal(new[] { source }, h.Salvages[0].ItemGuids); + + h.Objects.Get(source)!.Structure = 100; + Assert.False(h.Controller.TrySalvageItemsForAutomation(tool, [source])); + Assert.Single(h.Salvages); + } + [Fact] public void MatchingInventoryFailureReleasesGlobalRequest() { diff --git a/tests/AcDream.App.Tests/UI/Layout/ProjectileDebugOverlayControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ProjectileDebugOverlayControllerTests.cs new file mode 100644 index 00000000..087470ff --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ProjectileDebugOverlayControllerTests.cs @@ -0,0 +1,51 @@ +using System.Numerics; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.Tests.UI.Layout; + +public sealed class ProjectileDebugOverlayControllerTests +{ + [Fact] + public void ProjectsTransientClearAndBlockedSamplesWithoutConsumingInput() + { + IReadOnlyList samples = + [ + new(new Vector3(0f, 0f, -10f), true, 0.4f), + new(new Vector3(1f, 0f, -10f), false, 0.4f), + ]; + var root = new UiRoot { Width = 800f, Height = 600f }; + Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView( + MathF.PI / 2f, + 4f / 3f, + 0.1f, + 100f); + ProjectileDebugOverlayController controller = + ProjectileDebugOverlayController.Mount( + root, + () => samples, + () => (Matrix4x4.Identity, projection, new Vector2(800f, 600f))); + + controller.Tick(); + + UiPanel overlay = Assert.IsType(Assert.Single(root.Children)); + Assert.True(overlay.Visible); + Assert.True(overlay.ClickThrough); + Assert.Equal(2, overlay.Children.Count); + UiPanel clear = Assert.IsType(overlay.Children[0]); + UiPanel blocked = Assert.IsType(overlay.Children[1]); + Assert.True(clear.Visible); + Assert.True(blocked.Visible); + Assert.Equal(new Vector4(0f, 1f, 0f, 0.95f), clear.BorderColor); + Assert.Equal(new Vector4(1f, 0f, 0f, 0.95f), blocked.BorderColor); + Assert.InRange(clear.Left, 380f, 400f); + Assert.InRange(clear.Top, 280f, 300f); + + samples = []; + controller.Tick(); + + Assert.False(overlay.Visible); + Assert.All(overlay.Children, static child => Assert.False(child.Visible)); + } +} diff --git a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs index 659a0f90..bb3cb9b1 100644 --- a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs +++ b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs @@ -4,6 +4,79 @@ namespace AcDream.App.Tests.UI; public class MarkupDocumentTests { + private sealed class EditorBinding + { + public string Draft { get; private set; } = "initial"; + public string Submitted { get; private set; } = string.Empty; + public string Selected { get; private set; } = "First"; + public int SelectedIndex { get; private set; } + public IReadOnlyList Choices => ["First", "Second"]; + public IReadOnlyList ChoiceColors => [0xFF0000u, 0x00FF00u]; + public Action ChangeDraft => value => Draft = value; + public Action SubmitDraft => value => Submitted = value; + public Action SelectChoice => value => Selected = value; + public Action SelectIndex => value => SelectedIndex = value; + } + + [Fact] + public void FieldAndMenuBindEditablePluginState() + { + const string xml = """ + + + + + + """; + var binding = new EditorBinding(); + + UiNineSlicePanel panel = MarkupDocument.Build( + xml, + binding, + _ => (1u, 32, 32)); + + UiField field = Assert.IsType(panel.Children[0]); + UiMenu menu = Assert.IsType(panel.Children[1]); + UiMarkupList list = Assert.IsType(panel.Children[2]); + field.SetText("named profile"); + field.OnSubmit?.Invoke(field.Text); + menu.OnSelect?.Invoke("Second"); + list.OnEvent(new UiEvent + { + Type = UiEventType.MouseDown, + Data2 = 19, + }); + + Assert.Equal("named profile", binding.Draft); + Assert.Equal("named profile", binding.Submitted); + Assert.Equal("Second", binding.Selected); + Assert.Equal(1, binding.SelectedIndex); + Assert.Equal(2, menu.Items.Count); + Assert.Equal([0xFF0000u, 0x00FF00u], list.ItemColorsSource()); + Assert.False(menu.OpenUpward); + } + + [Fact] + public void ControlIdAndNameBecomeStablePluginControlNames() + { + const string xml = """ + +